Fastlane CI skill for Claude Code

One command from commit to TestFlight and Play. Claude turns laptop releases into reproducible fastlane lanes: Gemfile-pinned fastlane, match certificates fetched read-only behind setup_ci, App Store Connect API keys instead of Apple ID sessions, build numbers derived from the store, and Play uploads that land on internal and promote with a staged rollout. A bundled checker flags committed secrets and risky lanes before CI runs them.

  • namefastlane-ci
  • size230 lines · ≈2,600 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Automates iOS and Android builds, signing, and store uploads with fastlane in CI — Gemfile-pinned fastlane, Appfile and Fastfile lanes, match certificate storage with readonly CI access, setup_ci keychains, App Store Connect API keys, automatic build numbers, TestFlight and Play internal, beta and staged production tracks, Play service-account JSON, and GitHub Actions or other runner workflows with caching. Use when writing or fixing a Fastfile, CI signing fails or hangs, 2FA breaks uploads, build numbers collide, or moving releases from a laptop to CI.

Try asking

  • Set up fastlane lanes so GitHub Actions uploads iOS builds to TestFlight and Android to Play internal
  • Our CI hangs at codesign after switching to match — fix the Fastfile
  • Review this Fastfile for anything that could leak secrets or break production releases

Use it when

  • Writing or reviewing a Fastfile, Appfile, or Matchfile
  • Moving mobile signing and uploads from a laptop into CI
  • Uploads fail on 2FA, duplicate build numbers, or Play permissions
  • Adding staged Play rollouts or tag-triggered TestFlight builds

Skip it when

  • Deciding review strategy, privacy labels, or rollout halt thresholds — use app-store-release
  • Building with EAS Build and EAS Submit in an Expo app — use react-native-expo

How to check the result

check_fastlane.py reports no errors, and two consecutive CI runs each upload a build with the next build number using read-only signing.

The common mistake it prevents

Letting CI run match without readonly or using Apple ID auth, so certificates get regenerated or 2FA sessions expire and every pipeline breaks at once.

~/.claude/skills/fastlane-ci/SKILL.md

--- name: fastlane-ci description: Automates iOS and Android builds, signing, and store uploads with fastlane in CI — Gemfile-pinned fastlane, Appfile and Fastfile lanes,… license: MIT ---

Done well means: one command per destination (bundle exec fastlane ios beta, bundle exec fastlane android internal) that runs identically on a laptop and in CI, signs with read-only certificates from match, authenticates with API keys instead of an Apple ID, numbers builds automatically, and never ships to 100% of production in one step.

Store policy, review, and rollout judgement live in app-store-release; this skill is the automation layer.

Before changing anything

  • Run bundle exec fastlane lanes and read fastlane/Fastfile, Appfile, Matchfile, Pluginfile, Gemfile.lock. Note the fastlane version and plugins.
  • Run the bundled checker for known hazards: python3 scripts/check_fastlane.py <project-root> (exit 1 means secrets or destructive actions are committed; fix those first).
  • Identify current signing: Xcode automatic signing, manual profiles, or match (which storage: git, google_cloud, s3, gitlab_secure_files).
  • List the CI secrets that exist. Never print them in logs while investigating.

Workflow

- [ ] 1. Gemfile with fastlane pinned; Gemfile.lock committed; always `bundle exec`
- [ ] 2. Appfile with app_identifier / package_name (and team_id if several teams)
- [ ] 3. App Store Connect API key (.p8) stored as a base64 CI secret
- [ ] 4. match repo/bucket initialized once by a human; CI uses readonly
- [ ] 5. Lanes: ios beta, ios release, android internal, android promote
- [ ] 6. Build numbers derived from the store (or CI run number), never hand-edited
- [ ] 7. CI workflow with Ruby + bundler cache, Xcode pinned, secrets as env vars
- [ ] 8. Dry run on a branch, then protect release lanes behind tags or manual approval

Setup

# Gemfile
source "https://rubygems.org"
gem "fastlane", "~> 2.228"      # pin a minor range; bump deliberately
plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile")
eval_gemfile(plugins_path) if File.exist?(plugins_path)
# fastlane/Appfile
app_identifier(ENV.fetch("APP_IDENTIFIER", "com.acme.app"))
team_id(ENV["APPLE_TEAM_ID"])                 # only if the key/account spans teams
package_name("com.acme.app")

For Play, pass the service-account JSON from a secret as json_key_data: ENV["PLAY_JSON_KEY"] on each Play action (or write it to a temp file and set json_key:); never commit the file.

Signing with match

DecisionDefault
StoragePrivate git repo with a deploy key, or google_cloud / s3 bucket if git access is awkward
Typesdevelopment for local, appstore for TestFlight and App Store, adhoc only for device farms
Who writesOne human on a trusted machine runs fastlane match appstore once per new app or cert expiry
CImatch(type: "appstore", readonly: true) — never creates or revokes anything
EncryptionMATCH_PASSWORD secret; rotate if anyone with access leaves
  • fastlane match nuke revokes all certificates of a type for the whole team and breaks every other machine. Run it only manually and only after the user explicitly confirms.
  • Apple allows few distribution certificates per account; match shares one across the team instead of each developer creating their own.
  • New bundle IDs, App Groups, or capabilities: regenerate profiles with a writable run (match appstore --force_for_new_devices or --force) from a trusted machine, then CI picks them up read-only.
  • Set setup_ci in before_all so CI uses a temporary keychain. Without it, codesign on a CI macOS runner waits forever for a keychain password prompt.

iOS lanes

default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if is_ci
  end

  private_lane :asc_key do
    app_store_connect_api_key(
      key_id: ENV.fetch("ASC_KEY_ID"),
      issuer_id: ENV.fetch("ASC_ISSUER_ID"),
      key_content: ENV.fetch("ASC_KEY_P8_BASE64"),
      is_key_content_base64: true,
      in_house: false
    )
  end

  desc "Build and upload to TestFlight"
  lane :beta do
    api_key = asc_key
    match(type: "appstore", readonly: is_ci, api_key: api_key)
    increment_build_number(
      xcodeproj: "App.xcodeproj",
      build_number: latest_testflight_build_number(api_key: api_key, initial_build_number: 0) + 1
    )
    build_app(scheme: "App", configuration: "Release", export_method: "app-store",
              clean: true, output_directory: "build")
    upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true,
                         changelog: ENV.fetch("CHANGELOG", "Internal build"))
  end
end
  • latest_testflight_build_number queries the store, so two CI runs can race. Serialize release lanes (concurrency group) or use the CI run number as the build number.
  • skip_waiting_for_build_processing: true saves 10-30+ minutes of macOS time. Leave it false only when the same lane distributes to external testers.
  • Pin Xcode on the runner (xcodes action or sudo xcode-select -s). Apple raises the minimum SDK for uploads each spring; a runner image update can silently change Xcode.

Android lanes

platform :android do
  desc "Build AAB and upload to internal testing"
  lane :internal do
    code = (google_play_track_version_codes(track: "internal").max || 0) + 1
    gradle(
      task: "bundle", build_type: "Release",
      properties: {
        "versionCode" => code,                                   # read in build.gradle.kts
        "android.injected.signing.store.file" => ENV.fetch("UPLOAD_KEYSTORE_PATH"),
        "android.injected.signing.store.password" => ENV.fetch("UPLOAD_KEYSTORE_PASSWORD"),
        "android.injected.signing.key.alias" => ENV.fetch("UPLOAD_KEY_ALIAS"),
        "android.injected.signing.key.password" => ENV.fetch("UPLOAD_KEY_PASSWORD")
      }
    )
    upload_to_play_store(track: "internal", json_key_data: ENV.fetch("PLAY_JSON_KEY"),
                         aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
                         skip_upload_metadata: true, skip_upload_images: true,
                         skip_upload_screenshots: true)
  end

  desc "Promote internal to production at 10%"
  lane :promote do
    upload_to_play_store(track: "internal", track_promote_to: "production",
                         json_key_data: ENV.fetch("PLAY_JSON_KEY"),
                         skip_upload_aab: true, skip_upload_apk: true,
                         rollout: "0.1", skip_upload_changelogs: false)
  end
end
  • The keystore here is the upload key; Play App Signing holds the app signing key. Decode the keystore from a base64 secret into a temp file in a CI step.
  • Make versionCode a Gradle property with a local default so local builds still work.
  • Validate the service account once: bundle exec fastlane run validate_play_store_json_key json_key:path.json.
  • The very first upload of a new app must be done manually in Play Console; the API cannot create the app listing.
  • Draft apps (never published) accept only release_status: "draft" uploads.

CI workflow (GitHub Actions)

name: ios-beta
on:
  workflow_dispatch:
  push:
    tags: ["v*"]
concurrency: { group: ios-release, cancel-in-progress: false }
jobs:
  beta:
    runs-on: macos-15
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with: { bundler-cache: true }
      - run: sudo xcode-select -s /Applications/Xcode_26.0.app   # pin; check runner image
      - run: bundle exec fastlane ios beta
        env:
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          FASTLANE_SKIP_UPDATE_CHECK: "1"
          FASTLANE_HIDE_CHANGELOG: "1"

Check which Xcode versions the runner image actually ships before pinning a path. Cache SwiftPM (~/Library/Caches/org.swift.swiftpm, SourcePackages) and Gradle (actions/setup-java with cache: gradle) to cut build time. Android lanes run on ubuntu-latest; keep macOS minutes for iOS only.

Failure triage

SymptomLikely causeFix
Hangs at codesign / errSecInternalComponentLogin keychain on CIsetup_ci in before_all
“No profiles for ‘com.x’ were found”Profile missing the bundle ID, capability, or extension targetWritable match run from trusted machine; include every target’s bundle ID in app_identifier array
2FA / session expiredApple ID authSwitch every action to api_key:
“Redundant binary upload” / build number existsReused build numberDerive from latest_testflight_build_number + 1
Play: “Version code has already been used”SameDerive from google_play_track_version_codes across all tracks
Play: 403 / “caller does not have permission”Service account not invited in Play Console or missing release permissionInvite account in Users and permissions for this app
Works locally, fails in CIDifferent Xcode, Ruby, or fastlane versionPin Xcode, commit Gemfile.lock, bundle exec everywhere

Gotchas

  • Running fastlane without bundle exec uses whatever global version is installed.
  • App extensions (widgets, notification service) need their own bundle IDs in match and their own profiles; pass an array to app_identifier.
  • increment_build_number edits the project; commit it back only if your process wants that, otherwise keep build numbers ephemeral in CI.
  • Secrets passed via sh("... #{ENV['X']}") appear in logs; pass via env instead.
  • Fastlane’s Apple ID password env vars and app-specific passwords are legacy for uploads; App Store Connect API keys cover TestFlight, metadata, and match.

Verification

  1. python3 scripts/check_fastlane.py . reports 0 errors; review each warning.
  2. bundle exec fastlane lanes lists the expected lanes with descriptions.
  3. Run the beta lane from CI on a branch; confirm the build appears in TestFlight and Play internal testing with the expected build number and version code.
  4. Re-run the same workflow: it must succeed with the next build number, proving numbering and read-only signing are stable.
  5. Confirm production lanes require a tag or manual approval and use a staged rollout.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/check_fastlane.py 168 lines · 8.2 KB
#!/usr/bin/env python3
"""Static sanity check for a fastlane setup (stdlib only, no Ruby needed).

Usage:
  python3 check_fastlane.py <project-root-or-fastlane-dir>

Reads fastlane/Fastfile, fastlane/Appfile, fastlane/Matchfile (optional), Gemfile and
Gemfile.lock, and flags CI hazards: hardcoded secrets, match without readonly, missing
setup_ci, Apple ID password auth instead of an App Store Connect API key, uploads that
block on processing, missing build-number strategy, Play production uploads without a
staged rollout, and destructive actions committed to lanes.

Heuristic: it pattern-matches Ruby text, so treat findings as review prompts.
Exit 0 = no errors (warnings allowed), 1 = errors found, 2 = usage error.
"""
import re
import sys
from pathlib import Path

errors, warnings, infos = [], [], []


def strip_comments(text: str) -> str:
    out = []
    for line in text.splitlines():
        # drop Ruby line comments that are not inside a string (good enough for Fastfiles)
        m = re.match(r'^((?:[^#"\']|"[^"]*"|\'[^\']*\')*)#.*$', line)
        out.append(m.group(1) if m else line)
    return "\n".join(out)


def calls(text: str, action: str):
    """Yield the argument text of each call to `action`, handling ( ... ) spanning lines."""
    for m in re.finditer(rf'(?<![\w.]){action}\b', text):
        rest = text[m.end():]
        if rest.startswith("("):
            depth, i = 0, 0
            for i, ch in enumerate(rest):
                depth += ch == "("
                depth -= ch == ")"
                if depth == 0:
                    break
            yield rest[1:i]
        else:
            yield rest.split("\n", 1)[0]


SECRET_PATTERNS = [
    (r'password\s*[:=]\s*["\'][^"\']{4,}["\']', "hardcoded password"),
    (r'-----BEGIN (?:EC |RSA )?PRIVATE KEY-----', "private key pasted into the file"),
    (r'key_content\s*:\s*["\'][A-Za-z0-9+/=]{40,}["\']', "inline App Store Connect key content"),
    (r'json_key_data\s*:\s*["\']\{', "inline Play service-account JSON"),
    (r'"private_key"\s*:', "service-account private key in file"),
]


def check_fastfile(path: Path):
    raw = path.read_text(encoding="utf-8", errors="replace")
    text = strip_comments(raw)
    lanes = re.findall(r'^\s*(?:private_)?lane\s+:(\w+)', text, re.M)
    infos.append(f"lanes: {', '.join(lanes) or '(none found)'}")
    if not lanes:
        errors.append("Fastfile defines no lanes")

    for pat, label in SECRET_PATTERNS:
        if re.search(pat, raw):
            errors.append(f"Fastfile: {label} — read it from ENV or a CI secret instead")

    if re.search(r'match_nuke|\bmatch\s*\(\s*["\']?nuke|nuke\b', text):
        errors.append("Fastfile: 'match nuke' in a lane revokes every certificate — run it manually, never from CI")

    signing = list(calls(text, "match")) + list(calls(text, "sync_code_signing"))
    for args in signing:
        if "readonly" not in args:
            warnings.append("match/sync_code_signing without readonly: CI may try to create certs — pass readonly: is_ci (or true)")
        elif re.search(r'readonly\s*:\s*false', args):
            warnings.append("match called with readonly: false — only a human on a trusted machine should create certificates")
    if signing and "setup_ci" not in text and "create_keychain" not in text:
        warnings.append("match used but no setup_ci/create_keychain: on CI macOS runners signing hangs on the login keychain")

    ios_upload = any(a in text for a in ("upload_to_testflight", "pilot", "upload_to_app_store", "deliver"))
    if ios_upload and "app_store_connect_api_key" not in text and not re.search(r'api_key(_path)?\s*:', text):
        warnings.append("App Store uploads without app_store_connect_api_key: Apple ID + 2FA sessions expire and break CI")
    for args in calls(text, "upload_to_testflight"):
        if "skip_waiting_for_build_processing" not in args:
            warnings.append("upload_to_testflight waits for processing (often 10-30+ min of paid macOS time) — set skip_waiting_for_build_processing: true unless you distribute in the same lane")

    if any(a in text for a in ("build_app", "gym", "build_ios_app")):
        if not re.search(r'increment_build_number|latest_testflight_build_number|app_store_build_number|CURRENT_PROJECT_VERSION|build_number', text):
            warnings.append("iOS build has no build-number strategy — duplicate build numbers are rejected on upload")
        if not re.search(r'export_method|export_options', text):
            infos.append("build_app without export_method: relies on defaults; set export_method: \"app-store\" explicitly")

    for args in calls(text, "upload_to_play_store") or []:
        if "track" not in args:
            warnings.append("upload_to_play_store without track: defaults to production")
        elif re.search(r'track\s*:\s*["\']production["\']', args) and "rollout" not in args:
            warnings.append("Play production upload without rollout: ships to 100% at once — use rollout: \"0.1\" and promote")
    for args in calls(text, "supply"):
        if "track" not in args:
            warnings.append("supply without track: defaults to production")
    if "gradle" in text and re.search(r'upload_to_play_store|supply', text):
        if not re.search(r'versionCode|version_code|google_play_track_version_codes|build_number', text, re.I):
            warnings.append("Android upload with no visible versionCode strategy — Play rejects reused version codes")

    if re.search(r'\bsh\s*\(\s*["\']rm -rf', text):
        warnings.append("Fastfile runs 'rm -rf' via sh — confirm the path cannot be empty or outside the workspace")
    if "ensure_git_status_clean" not in text and re.search(r'commit_version_bump|push_to_git_remote|add_git_tag', text):
        warnings.append("lane commits/tags/pushes without ensure_git_status_clean first")


def check_appfile(path: Path):
    raw = path.read_text(encoding="utf-8", errors="replace")
    text = strip_comments(raw)
    for pat, label in SECRET_PATTERNS:
        if re.search(pat, raw):
            errors.append(f"Appfile: {label}")
    if not re.search(r'app_identifier|package_name', text):
        warnings.append("Appfile sets neither app_identifier nor package_name")
    if re.search(r'json_key_file\s*\(?\s*["\'][^"\']*\.json', text) and not re.search(r'ENV', text):
        infos.append("Appfile points at a committed json_key_file path — make sure that file is gitignored and injected by CI")


def check_matchfile(path: Path):
    text = strip_comments(path.read_text(encoding="utf-8", errors="replace"))
    m = re.search(r'storage_mode\s*\(?\s*["\'](\w+)["\']', text)
    infos.append(f"match storage: {m.group(1) if m else 'git (default)'}")
    if re.search(r'git_url\s*\(?\s*["\']https://[^"\']*:[^"\']*@', text):
        errors.append("Matchfile: credentials embedded in git_url — use MATCH_GIT_BASIC_AUTHORIZATION or a deploy key")


def main():
    if len(sys.argv) != 2:
        print(__doc__)
        sys.exit(2)
    root = Path(sys.argv[1])
    fl = root if root.name == "fastlane" else root / "fastlane"
    fastfile = fl / "Fastfile"
    if not fastfile.is_file():
        print(f"error: no Fastfile at {fastfile}")
        sys.exit(2)
    check_fastfile(fastfile)
    for name, fn in (("Appfile", check_appfile), ("Matchfile", check_matchfile)):
        if (fl / name).is_file():
            fn(fl / name)
        elif name == "Appfile":
            warnings.append("no Appfile: identifiers must then be passed to every action")
    project = fl.parent
    gemfile, lock = project / "Gemfile", project / "Gemfile.lock"
    if not gemfile.is_file():
        warnings.append("no Gemfile: pin fastlane with a Gemfile and run 'bundle exec fastlane' for reproducible CI")
    elif not lock.is_file():
        warnings.append("Gemfile without Gemfile.lock: commit the lockfile so CI uses the same fastlane version")
    elif "fastlane" not in gemfile.read_text(encoding="utf-8", errors="replace"):
        warnings.append("Gemfile does not list fastlane")

    for i in infos:
        print(f"info:  {i}")
    for w in dict.fromkeys(warnings):
        print(f"warn:  {w}")
    for e in dict.fromkeys(errors):
        print(f"ERROR: {e}")
    print(f"\n{len(set(errors))} error(s), {len(set(warnings))} warning(s)")
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc