30 квітня 2026 р. · 15 min read

Fastlane: Make and Ship App Store Screenshots (2026 Guide)

fastlane is a Ruby toolchain for automating the boring parts of shipping iOS and Android apps. Its three screenshot-related actions — snapshot, frameit, and deliver — cover the full pipeline: drive your app inside an XCUITest to capture raw images, wrap them in device frames with marketing copy, and push the result to App Store Connect. This guide walks through the pipeline end-to-end with the configuration files, lane definitions, and CI workflow you need to run it.

By the end you will have: a Ruby Bundler-pinned setup, an App Store Connect API key, a working Snapfile + SnapshotHelper.swift, a Framefile.json with per-screenshot keywords and titles, a Deliverfile tuned for screenshot-only uploads, a four-lane Fastfile, and a GitHub Actions workflow that runs the whole thing on a macos-26 runner.

1. Prerequisites and Mental Model

Before any Ruby gets installed, get the conceptual picture clear. The screenshot pipeline has three independent stages, each owned by a separate fastlane action:

  1. Capture — an XCUITest runs against a simulator and calls snapshot("01-Home") at the moments you want recorded. fastlane's snapshot action launches the right simulators, switches each one to the right locale, runs the test, and pulls the resulting PNGs into fastlane/screenshots/<locale>/.
  2. Frameframeit reads each PNG, picks a device frame based on the image resolution, optionally composites a background and marketing title, and writes a _framed.png alongside the original.
  3. Uploaddeliver walks the screenshots folder, matches each image to an App Store Connect display family (e.g. iPhone 6.9", iPad 13"), and replaces the screenshot set on the editable App Store version via the App Store Connect API.

Each stage is independently runnable. You can take screenshots without uploading them, frame screenshots produced by another tool, or upload pre-built screenshots without ever invoking the simulator.

2. Install fastlane the Sane Way: Bundler

Don't brew install fastlane. Pin fastlane per-project with Bundler so every machine that builds your project — your laptop, a teammate's laptop, CI — runs the same version. From the project root:

# system Ruby on macOS 14+ is fine, but rbenv/asdf is cleaner
gem install bundler
bundle init
echo 'gem "fastlane"' >> Gemfile
bundle install --path vendor/bundle

# from now on, run fastlane via:
bundle exec fastlane <lane>

Commit Gemfile and Gemfile.lock. Add vendor/bundle to your .gitignore. Now initialize fastlane in the project:

bundle exec fastlane init
# choose option 4: "Manual setup"

This creates a fastlane/ directory with Fastfile and Appfile. Fill in your bundle identifier and team ID in Appfile:

# fastlane/Appfile
app_identifier("com.example.myapp")
apple_id("[email protected]")     # only needed if you fall back to legacy auth
team_id("ABCDE12345")           # Developer Portal team ID

3. The App Store Connect API Key

Username/password auth is deprecated for new accounts and two-factor-protected for everyone else, which makes it useless on CI. Use an App Store Connect API key instead. In App Store Connect → Users and Access IntegrationsApp Store Connect API:

  1. Generate API Key (you only do this once per team; lost .p8 files cannot be re-downloaded).
  2. Give it the App Manager role. Developer is not enough to upload screenshots; Admin is more than you need.
  3. Note the Key ID (10 chars, e.g. ABCD1234EF) and the team's Issuer ID (UUID at the top of the same page).
  4. Download the AuthKey_ABCD1234EF.p8 file.

fastlane reads the key from a JSON file. Save it as fastlane/asc_api_key.json (and put that path in .gitignore):

// fastlane/asc_api_key.json
{
  "key_id": "ABCD1234EF",
  "issuer_id": "57246542-96fe-1a63-e053-0824d011072a",
  "key": "-----BEGIN PRIVATE KEY-----\nMIGTAg...truncated...A==\n-----END PRIVATE KEY-----",
  "duration": 1200,
  "in_house": false
}

duration is the lifetime of each generated JWT in seconds; the maximum Apple accepts is 1200 (20 minutes). The key field is the full contents of the .p8 file, including the BEGIN/END PRIVATE KEY lines, with literal \n for line breaks.

On CI, never check the JSON in. Store the JSON contents as a single secret (e.g. ASC_API_KEY_JSON) and write the file at run time — there is an example in the GitHub Actions section below.

4. snapshot — Capturing Screenshots in XCUITest

snapshot works by injecting a small Swift helper into your UI test target. The helper hooks into the test runtime so that every call to snapshot("name") from your test takes a screenshot of the simulator screen, names it, and writes it to a disk location fastlane already knows how to find.

Generate the helper

bundle exec fastlane snapshot init

This creates fastlane/Snapfile and fastlane/SnapshotHelper.swift. Add SnapshotHelper.swift to your UI testing target in Xcode (drag it in, make sure "MyAppUITests" is the only checked target — never include it in the app binary).

Snapfile

Snapfile tells snapshot which devices to spin up, which locales to run each test in, and which UI test scheme to invoke:

# fastlane/Snapfile

devices([
  "iPhone 17 Pro Max",     # 6.9"  -> 1320 x 2868 (required slot in 2026)
  "iPhone 17 Pro",         # 6.3"  -> 1206 x 2622 (optional, nicer in store listings)
  "iPad Pro 13-inch (M4)", # 13"   -> 2064 x 2752 (required if you ship an iPad build)
])

languages([
  "en-US",
  "de-DE",
  "fr-FR",
  "es-ES",
  "ja",
])

scheme("MyAppUITests")           # the UI-testing scheme that runs Snapshot tests
output_directory("./fastlane/screenshots")
clear_previous_screenshots(true)
override_status_bar(true)        # 9:41, full battery, full signal
concurrent_simulators(true)
stop_after_first_error(true)
number_of_retries(1)

Highlights:

  • devices — names must match exactly what xcrun simctl list devices prints. Apple changes names every year (e.g. "iPhone 17 Pro Max" replaced "iPhone 16 Pro Max"). If a device name is wrong, snapshot will silently skip it.
  • languages — pass either a region code like "en-US" or a bare language code like "ja". Each entry generates a folder under output_directory.
  • override_status_bar(true) — uses simctl status_bar override to show 9:41, full battery, and full Wi-Fi/cell signal in every screenshot. Apple does not technically require this, but most reviewers expect it.
  • concurrent_simulators — runs multiple simulators in parallel. Cuts wall time roughly by the number of devices, but each simulator costs ~3 GB of RAM, so it is brutal on under-resourced CI runners.
  • clear_previous_screenshots — deletes fastlane/screenshots/<locale>/ at the start of each run. Without this, stale shots from removed test cases pile up forever.

Wire snapshot into your XCUITest

SnapshotHelper exposes two free functions: setupSnapshot(_:) (call once per test) and snapshot(_:) (call wherever you want to record a frame).

// MyAppUITests/MyAppUITests.swift

import XCTest

final class MyAppUITests: XCTestCase {
    override func setUpWithError() throws {
        continueAfterFailure = false
        let app = XCUIApplication()
        setupSnapshot(app)               // injects the locale + screenshot bridge
        app.launchArguments += [
            "-UITests",
            "-AppleLanguages", "(\(Snapshot.deviceLanguage))",
            "-AppleLocale", Snapshot.currentLocale,
        ]
        app.launch()
    }

    func testScreenshots() {
        let app = XCUIApplication()

        snapshot("01-Home")              // tap pattern: drive UI, then snapshot

        app.tabBars.buttons["Library"].tap()
        snapshot("02-Library")

        app.cells.element(boundBy: 0).tap()
        snapshot("03-Detail")

        app.navigationBars.buttons.element(boundBy: 0).tap()
        app.tabBars.buttons["Settings"].tap()
        snapshot("04-Settings")
    }
}

A few practical notes:

  • Pass -UITests as a launch argument and check for it in your app to disable analytics, swap in deterministic seed data, skip onboarding, or stub network calls. Reviewers (and your future self) will thank you.
  • snapshot blocks until the screen capture completes, so you can immediately drive the next interaction afterwards.
  • If the UI animates, add UIView.setAnimationsEnabled(false) behind a launch argument. Animation mid-capture produces blurry shots.
  • Name screenshots with a numeric prefix (01-, 02-) so the filename order matches App Store Connect display order. deliver uploads them in lexical order.

Run snapshot

bundle exec fastlane snapshot

# or, equivalently, in a Fastfile lane:
#   capture_ios_screenshots

Output lands in fastlane/screenshots/<locale>/iPhone 17 Pro Max-01-Home.png and friends. Snapshot also generates screenshots.html — open it to flip through all locales and devices in a single page.

5. frameit — Adding Device Frames and Titles

Raw screenshots are bare device viewports. frameit wraps them in physical device frames, optionally adds a background, a marketing title, and a smaller "keyword" line above it.

# Download device frames once (cached under ~/.frameit/)
bundle exec fastlane frameit download_frames

# Frame everything in fastlane/screenshots, including subfolders
bundle exec fastlane frameit --use_platform IOS

For each foo.png in the screenshots tree, frameit writes foo_framed.png next to it. Originals are left in place; deliver picks up the framed version automatically when present (and falls back to the raw version if not).

Framefile.json

Default frames look like a dev test — black frame, no background, no text. Configure them with fastlane/screenshots/Framefile.json:

// fastlane/screenshots/Framefile.json
{
  "device_frame_version": "latest",
  "default": {
    "keyword": {
      "font": "./fonts/Inter-SemiBold.ttf",
      "color": "#FFFFFF",
      "padding": 50
    },
    "title": {
      "font": "./fonts/Inter-Bold.ttf",
      "color": "#FFFFFF"
    },
    "background": "./background.png",
    "padding": 80,
    "show_complete_frame": false,
    "title_below_image": false,
    "stack_title": true
  },
  "data": [
    {
      "filter": "Home",
      "keyword": { "color": "#9F7AEA" },
      "frame": "BLACK"
    },
    {
      "filter": "Settings",
      "keyword": { "color": "#3B82F6" },
      "frame": "WHITE"
    }
  ]
}

Key fields:

  • background — a PNG that the framed device is composited onto. Should be wider than your largest screenshot output. frameit centers the device vertically by default; title_below_image and show_complete_frame control how text and frame interact.
  • data[] — per-screenshot overrides keyed by a substring filter against the screenshot filename. The second entry above only applies to files with "Settings" in the name. Use this for color themes per row.
  • frame — overrides the device frame color for matching files. The values frameit recognizes in Framefile.json are BLACK, WHITE, GOLD, and ROSE_GOLD. The frame artwork itself is downloaded from Apple's marketing-resources Sketch files via fastlane frameit download_frames.

Per-locale title.strings and keyword.strings

Marketing copy comes from two parallel per-locale strings files — title.strings for the headline and keyword.strings for the smaller label above it. Keys are the screenshot filenames without extension or device prefix:

/* fastlane/screenshots/en-US/title.strings */

"01-Home"     = "Track every match.\nIn one tap.";
"02-Library"  = "Your full history,\nalways with you.";
"03-Detail"   = "Drill into any session.";
"04-Settings" = "Sync across all devices.";
/* fastlane/screenshots/en-US/keyword.strings */
/* keywords are rendered above the title, smaller */

"01-Home"     = "FAST";
"02-Library"  = "ORGANIZED";
"03-Detail"   = "DEEP";
"04-Settings" = "EVERYWHERE";

Translations go in fastlane/screenshots/de-DE/title.strings and fastlane/screenshots/de-DE/keyword.strings, fastlane/screenshots/ja/title.strings, and so on. Use \n for line breaks. Missing locales fall back to the untranslated screenshot (no title rendered) — there is no automatic English fallback.

Fonts

Drop TTF or OTF files in fastlane/screenshots/fonts/ and reference them with a relative path in Framefile.json. Variable fonts are supported. If you reference a font that does not exist, frameit silently falls back to a default sans-serif and the result will look wrong without warning.

6. deliver — Uploading to App Store Connect

deliver walks the screenshots folder, matches each PNG to an App Store Connect display family, then talks to the App Store Connect API to replace the screenshot set on the currently editable version. With API key auth and a focused Deliverfile, the upload step is one command.

Folder layout

deliver expects a flat per-locale layout — no device subfolders. Display family is detected from the image resolution (and, when ambiguous, from the device prefix in the filename that snapshot already adds):

fastlane/screenshots/
├── en-US/
│   ├── iPhone 17 Pro Max-01-Home_framed.png       # 6.9" iPhone
│   ├── iPhone 17 Pro Max-02-Library_framed.png
│   ├── iPad Pro 13-inch (M4)-01-Home_framed.png   # 13" iPad
│   ├── title.strings
│   └── keyword.strings
├── de-DE/
│   └── ...
└── Framefile.json

Deliverfile

# fastlane/Deliverfile

app_identifier("com.example.myapp")
team_id("ABCDE12345")

# Auth via App Store Connect API key (preferred over username/password).
api_key_path("./fastlane/asc_api_key.json")

# Where to read screenshots / metadata from.
screenshots_path("./fastlane/screenshots")
metadata_path("./fastlane/metadata")

# Only push screenshots — leave the binary, pricing, IAPs, etc. alone.
skip_binary_upload(true)
skip_metadata(false)
skip_screenshots(false)
skip_app_version_update(true)

# Don't ask for confirmation in CI.
force(true)
overwrite_screenshots(true)
run_precheck_before_submit(false)
submit_for_review(false)

# Match a screenshot file against the right App Store display family.
# Useful when fastlane's resolution-based detection is ambiguous.
ignore_language_directory_validation(false)

The flags that matter most:

  • skip_binary_upload — without this, deliver looks for an IPA and refuses to run if none is present.
  • skip_metadata — set to true if you only want screenshots and have no fastlane/metadata/ directory. Set to false to ship localized titles, descriptions, keywords, and promotional text in the same call.
  • force(true) — skips the "Are you sure?" prompt that deliver shows before pushing. Required on CI.
  • overwrite_screenshots(true) — wipes the existing screenshot set in each display family before uploading new shots. Without this, new uploads append to old ones up to the 10-screenshot limit, then fail.
  • run_precheck_before_submit(false) precheck scans metadata for risky terms ("Beta", competitor names, censored words). It is great pre-submit but wrong for screenshot-only updates.

Dry-run, then ship

# verify which display families and locales will be touched, no upload
bundle exec fastlane deliver --verify_only

# inspect the resolved configuration
bundle exec fastlane deliver --print_resolved_options

# real upload
bundle exec fastlane deliver

On the first real upload, deliver prints an HTML preview at fastlane/preview.html and waits for confirmation unless force(true) is set. Always open the preview the first time on a new device family — that is when display-family mismatches show up.

7. The Full Fastfile

One file with four lanes covering capture, frame, capture+frame, and full ship. Local development uses one lane; CI uses another.

# fastlane/Fastfile

default_platform(:ios)

platform :ios do
  desc "Capture screenshots with snapshot"
  lane :screenshots do
    capture_ios_screenshots         # alias for snapshot
  end

  desc "Frame screenshots with frameit"
  lane :frame do
    frame_screenshots(
      path: "./fastlane/screenshots",
      use_platform: "IOS"
    )
  end

  desc "Build framed screenshots end-to-end"
  lane :build_marketing do
    capture_ios_screenshots
    frame_screenshots(path: "./fastlane/screenshots", use_platform: "IOS")
  end

  desc "Upload screenshots to App Store Connect"
  lane :upload_screenshots do
    upload_to_app_store(             # alias for deliver
      skip_binary_upload: true,
      skip_metadata: true,
      skip_app_version_update: true,
      force: true,
      overwrite_screenshots: true,
      run_precheck_before_submit: false
    )
  end

  desc "Full release pipeline: capture, frame, upload"
  lane :ship_screenshots do
    capture_ios_screenshots
    frame_screenshots(path: "./fastlane/screenshots", use_platform: "IOS")
    upload_to_app_store(
      skip_binary_upload: true,
      skip_metadata: true,
      skip_app_version_update: true,
      force: true,
      overwrite_screenshots: true
    )
  end
end

capture_ios_screenshots, frame_screenshots, and upload_to_app_store are the canonical names of snapshot, frameit, and deliver. The short names still work but the long ones are clearer and don't shadow Ruby keywords.

8. CI on GitHub Actions

Screenshots are slow (15 minutes is typical for 5 locales × 3 devices) but they parallelize well across simulators. A single macos-26 runner with concurrent simulators is usually enough.

# .github/workflows/screenshots.yml

name: Screenshots

on:
  workflow_dispatch:
  push:
    paths:
      - "fastlane/**"
      - "MyAppUITests/**"

jobs:
  screenshots:
    runs-on: macos-26       # Xcode 26.x default; macos-15 also works with explicit xcode-select
    timeout-minutes: 90
    env:
      LC_ALL: en_US.UTF-8
      LANG: en_US.UTF-8
      FASTLANE_SKIP_UPDATE_CHECK: "1"
      FASTLANE_HIDE_CHANGELOG: "1"

    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_26.3.app

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
          bundler-cache: true

      - name: Write App Store Connect API key
        run: |
          mkdir -p fastlane
          echo "$ASC_API_KEY_JSON" > fastlane/asc_api_key.json
        env:
          ASC_API_KEY_JSON: ${{ secrets.ASC_API_KEY_JSON }}

      - name: Capture, frame, upload
        run: bundle exec fastlane ios ship_screenshots

      - name: Archive framed screenshots
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: screenshots
          path: fastlane/screenshots

Things that make CI runs reliable:

  • Pin Xcode. sudo xcode-select -s with an explicit path. GitHub-hosted runners ship multiple Xcode versions and the default is whichever they decided this month.
  • Pin the runner image. Use a versioned image like macos-26 or macos-15, not macos-latest. A runner upgrade three weeks before release is the kind of surprise you do not want.
  • Cache the Bundler install. bundler-cache: true in setup-ruby shaves a minute or two off every run.
  • Write the API key from a single secret. Storingkey_id, issuer_id, and key as separate secrets is more rotation-friendly but more error-prone. One JSON-shaped secret is fine for small teams.
  • Upload the framed screenshots as an artifact even when the upload step fails. You almost always want to inspect what was generated before re-running.

9. Common Errors and Fixes

"Could not find a device matching..."

Snapshot's device names must match xcrun simctl list devicetypes exactly. Apple ships a new top-of-line device every year and Apple's calendar-based versioning means the toolchain rolls forward fast. "iPhone 17 Pro Max" ships with Xcode 26 (released Fall 2025); on Xcode 16 the 6.9" device was "iPhone 16 Pro Max". After every Xcode upgrade, re-run xcrun simctl list devicetypes | grep iPhone and update Snapfile.

"Unable to verify upload" / 401 Unauthorized

Your JWT expired mid-upload, the API key has been revoked, or its role was downgraded. Check that duration in asc_api_key.json is at most 1200, then re-issue the key.

"App Store Connect is locked"

The version you are uploading to is in In Review or Pending Developer Release. Screenshots are read-only in those states. Create a new version (the "Prepare for Submission" slot) and re-run.

Screenshots end up in the wrong display family

deliver matches by image resolution. If you exported a 6.9" iPhone screenshot at 1320 × 2868 it will land in the 6.9" slot; if you scaled it to 1284 × 2778 it lands in the legacy 6.5" slot. If both display families exist on your version, supply the device family in the filename — that is the prefix snapshot already produces. Manual exports often skip it.

"Screenshot has alpha channel" / unexpected transparency

App Store Connect has historically rejected PNGs with transparency, and deliver still warns when it sees an alpha channel. Apple's current screenshot specification page no longer states the rule explicitly, but flattening alpha is the safe move. Run the file through sips -s format png with a PNG color profile, or re-export without an alpha channel. frameit's composited output is always opaque; raw simctl io ... screenshot output can include alpha if your root view is not opaque.

"Locale ru-RU does not exist for this app"

deliver only uploads to App Store Connect locales that already exist on the version. Add the locale in App Store Connect first (or set force_create_app(true) + metadata in deliver), then re-run.

Concurrent simulators eat all the RAM

Each iPhone 17 Pro Max simulator booted with the keyboard up costs ~2.5 GB. On a 16 GB MacBook running three at once will swap and crash mid-test. Set concurrent_simulators(false) locally; let CI run them in parallel on a beefier runner.

10. When fastlane Is Overkill

fastlane's screenshot pipeline is great when:

  • You already have a UI test target and your team is comfortable maintaining XCUITest fixtures.
  • You want screenshots to be a CI artifact, regenerated on every release.
  • You localize and want the layout to be defined once in code, not redrawn per language.

It is the wrong choice when:

  • You want full art direction over each screenshot — typography, composition, decorative elements. frameit is a templating engine, not a design tool.
  • You need to ship a marketing-quality screenshot of a feature that does not yet exist in the app, or that exists only behind a feature flag your test target cannot reach.
  • You are a solo dev who would rather design once in a Mac or iPad app and click Upload. The Ruby toolchain plus XCUITest plus Xcode-version pinning is real maintenance overhead.

For the design-first workflow, Screenshot Bro covers the same ground — localized layouts, device frames, one-click App Store Connect upload with the same API key — without the XCUITest plumbing.

TL;DR Cheat Sheet

# Setup (once)
bundle init && echo 'gem "fastlane"' >> Gemfile && bundle install
bundle exec fastlane init
bundle exec fastlane snapshot init
bundle exec fastlane frameit download_frames

# Per release
bundle exec fastlane ship_screenshots

That is the full pipeline. Two commands at install time, one command per release. The complexity hides in the configuration files above — and once they are written, they barely change.

Wondering whether to set this up at all, or to use a Mac/iPad app for the same job? See Fastlane snapshot vs Screenshot Bro for the side-by-side and the "use both" workflow.

Want the same upload flow without the XCUITest pipeline? Design and ship App Store screenshots from one Mac and iPad app.

Завантажити в App Store

Подивитися в дії