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 atbuild/fastlane/Filmtone.ipawith Xcode automatic signingscreenshots— runs the UI test rail on iPhone 17 Pro Max (hard-coded UDID), then stages output forjaanden-USmetadata— uploads localized metadata, review info, and release notes to App Store Connect. Never touches binary or screenshotsbeta— uploads an existing IPA to TestFlight. Always requires explicitIPA_PATHrelease— uploads IPA + metadata + screenshots to ASC.SUBMIT_FOR_REVIEW=1submits for review;AUTOMATIC_RELEASE=1auto-releases on approvalsubmit_review— re-submits an already-uploaded build for review without touching binary or screenshots, givenAPP_VERSIONandBUILD_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