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.
- name
fastlane-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.
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 lanesand readfastlane/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
| Decision | Default |
|---|---|
| Storage | Private git repo with a deploy key, or google_cloud / s3 bucket if git access is awkward |
| Types | development for local, appstore for TestFlight and App Store, adhoc only for device farms |
| Who writes | One human on a trusted machine runs fastlane match appstore once per new app or cert expiry |
| CI | match(type: "appstore", readonly: true) — never creates or revokes anything |
| Encryption | MATCH_PASSWORD secret; rotate if anyone with access leaves |
fastlane match nukerevokes 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_devicesor--force) from a trusted machine, then CI picks them up read-only. - Set
setup_ciinbefore_allso 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_numberqueries 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: truesaves 10-30+ minutes of macOS time. Leave it false only when the same lane distributes to external testers.- Pin Xcode on the runner (
xcodesaction orsudo 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
versionCodea 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
| Symptom | Likely cause | Fix |
|---|---|---|
Hangs at codesign / errSecInternalComponent | Login keychain on CI | setup_ci in before_all |
| “No profiles for ‘com.x’ were found” | Profile missing the bundle ID, capability, or extension target | Writable match run from trusted machine; include every target’s bundle ID in app_identifier array |
| 2FA / session expired | Apple ID auth | Switch every action to api_key: |
| “Redundant binary upload” / build number exists | Reused build number | Derive from latest_testflight_build_number + 1 |
| Play: “Version code has already been used” | Same | Derive 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 permission | Invite account in Users and permissions for this app |
| Works locally, fails in CI | Different Xcode, Ruby, or fastlane version | Pin Xcode, commit Gemfile.lock, bundle exec everywhere |
Gotchas
- Running
fastlanewithoutbundle execuses 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_numberedits 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
python3 scripts/check_fastlane.py .reports 0 errors; review each warning.bundle exec fastlane laneslists the expected lanes with descriptions.- 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.
- Re-run the same workflow: it must succeed with the next build number, proving numbering and read-only signing are stable.
- 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
#!/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.