Files
FamilyMealPlanner/fastlane/Fastfile
T
alexandrev-tibco 75c41b12ec comprobar el esquema de CloudKit antes de publicar
Un campo que esta en el @Model pero no en el esquema desplegado no sincroniza,
y no falla nada: los datos se quedan en el dispositivo que los escribio. Asi se
colaron nueve campos fuera de Production entre la 2.0 y la 2.1.2 sin que nadie
lo notara, incluidas las reglas de dia de las etiquetas y el desayuno/merienda
activados.

scripts/check_cloudkit_schema.py compara las propiedades almacenadas de cada
@Model con los CD_<campo> del esquema real (exportado con cktool), saltando
relaciones, computadas y el sufijo _ckAsset de los binarios.

fastlane submit y release abortan si falta algo; beta solo avisa, porque en
TestFlight es normal que el deploy a Production aun no se haya hecho. La lane
check_schema lo ejecuta suelto.

El deploy a Production sigue siendo manual: cktool no tiene subcomando y Apple
bloquea el endpoint de esquema en ese entorno.

Refs #38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
2026-09-14 17:23:22 +02:00

278 lines
11 KiB
Ruby

default_platform(:ios)
# Credentials: App Store Connect API key via `pass`.
# The api_key block uses the key inline — no session/2FA needed.
platform :ios do
EXPORT_OPTIONS = {
method: "app-store",
signingStyle: "manual",
manageAppVersionAndBuildNumber: false,
provisioningProfiles: {
"com.alexandrevazquez.mealmood" => "com.alexandrevazquez.mealmood AppStore",
"com.alexandrevazquez.mealmood.widget" => "com.alexandrevazquez.mealmood.widget AppStore",
"com.alexandrevazquez.mealmood.watchkitapp" => "com.alexandrevazquez.mealmood.watchkitapp AppStore",
"com.alexandrevazquez.mealmood.watchkitapp.widget" => "com.alexandrevazquez.mealmood.watchkitapp.widget AppStore"
}
}
def asc_api_key
app_store_connect_api_key(
key_id: `pass show appstore/api-key-id`.strip,
issuer_id: `pass show appstore/issuer-id`.strip,
key_content: `pass show appstore/api-key-p8`.strip,
in_house: false
)
end
# A field missing from the CloudKit schema doesn't crash anything — it just
# never syncs, silently. Nine of them drifted out of Production between 2.0
# and 2.1.2 before anyone noticed (issue #38), so shipping now checks first.
def check_cloudkit_schema(blocking:)
script = File.expand_path("../scripts/check_cloudkit_schema.py", __dir__)
ok = system("python3", script, "--environment", "production")
return if ok
if blocking
UI.user_error!("CloudKit Production schema is behind the models — deploy it before shipping (see the output above).")
else
UI.important("CloudKit Production schema is behind the models. Fine for TestFlight, but deploy it before submitting to the App Store.")
end
end
desc "Check the deployed CloudKit schema against the SwiftData models"
lane :check_schema do
check_cloudkit_schema(blocking: true)
end
desc "Push a new beta build to TestFlight"
lane :beta do
check_cloudkit_schema(blocking: false)
increment_build_number(xcodeproj: "MealMood.xcodeproj")
# No `sdk:` here: forcing one SDK globally breaks the watchOS targets,
# which must build against watchos within the same archive.
build_app(
scheme: "MealMood",
export_method: "app-store",
destination: "generic/platform=iOS",
export_options: EXPORT_OPTIONS
)
upload_to_testflight(
api_key: asc_api_key,
skip_waiting_for_build_processing: true
)
end
# Firebase arrives via SPM, so upload-symbols sits in DerivedData instead of
# the ./Pods path fastlane looks in by default.
def crashlytics_upload_symbols_binary
pattern = File.expand_path(
"~/Library/Developer/Xcode/DerivedData/MealMood-*/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols"
)
found = Dir.glob(pattern)
if found.empty?
UI.user_error!("upload-symbols not found. Build the project once so SPM checks out firebase-ios-sdk.")
end
found.max_by { |path| File.mtime(path) }
end
# Every dSYM (app + widget) of the most recently archived build.
def latest_archive_dsyms
pattern = File.expand_path("~/Library/Developer/Xcode/Archives/*/MealMood*.xcarchive/dSYMs/*.dSYM")
by_archive = Dir.glob(pattern).group_by { |path| path[/.*\.xcarchive/] }
newest = by_archive.max_by { |archive, _| File.mtime(archive) }
newest ? newest.last : []
end
# The shipped version lives in the Info.plist, not in MARKETING_VERSION.
# Absolute path: the lane's working directory is not guaranteed to be the repo root.
def shipped_app_version
plist = File.expand_path("../MealMood/Resources/Info.plist", __dir__)
value = `/usr/bin/plutil -extract CFBundleShortVersionString raw "#{plist}"`.strip
UI.user_error!("Could not read the app version from #{plist}") if value.empty?
value
end
# asc_api_key returns fastlane's option hash, not a Spaceship token.
def connect_api_app
require 'spaceship'
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
key_id: `pass show appstore/api-key-id`.strip,
issuer_id: `pass show appstore/issuer-id`.strip,
key: `pass show appstore/api-key-p8`.strip
)
Spaceship::ConnectAPI::App.find("com.alexandrevazquez.mealmood")
end
# `deliver` does not create the App Store version record — it fails with
# "could not find an editable version for 'IOS'" when the previous version is
# already READY_FOR_SALE and nothing is open for editing.
def ensure_editable_version(version)
app = connect_api_app
editable = app.get_edit_app_store_version(platform: "IOS")
if editable && editable.version_string != version
# ensure_version! would silently renumber it — say so before it happens.
UI.important("App Store Connect had #{editable.version_string} open for editing; renaming it to #{version}")
end
created = app.ensure_version!(version, platform: "IOS")
UI.success(created ? "App Store version #{version} ready" : "App Store version #{version} already editable")
end
desc "Show the latest TestFlight beta feedback (tester reports and crashes)"
lane :feedback do |options|
app = connect_api_app
# spaceship's get_beta_feedback hits v1/betaFeedbacks, a private endpoint
# Apple has since removed. These are the current public ones.
client = Spaceship::ConnectAPI.test_flight_request_client
limit = (options[:limit] || 10).to_i
["v1/apps/#{app.id}/betaFeedbackCrashSubmissions", "v1/apps/#{app.id}/betaFeedbackScreenshotSubmissions"].each do |path|
UI.header(path)
begin
resp = client.get(path, {
"include" => "build,tester",
"limit" => limit,
"sort" => "-createdDate"
})
rows = resp.body["data"] || []
# Resolve build ids so each report says which build it came from.
builds = {}
(resp.body["included"] || []).each do |inc|
builds[inc["id"]] = inc.dig("attributes", "version") if inc["type"] == "builds"
end
UI.important("none") if rows.empty?
rows.each do |row|
a = row["attributes"] || {}
build = builds[row.dig("relationships", "build", "data", "id")]
UI.message("── #{a['createdDate']} — build #{build || '?'}#{a['deviceModel']}#{a['osVersion']} — id #{row['id']}")
UI.message(" #{a['comment']}") if a["comment"]
end
rescue => e
UI.error("#{path}: #{e.message[0, 200]}")
end
end
end
desc "Download the crash log of a TestFlight feedback submission (id from `feedback`)"
lane :crashlog do |options|
UI.user_error!("pass id:<submission id>") unless options[:id]
connect_api_app
client = Spaceship::ConnectAPI.test_flight_request_client
resp = client.get("v1/betaFeedbackCrashSubmissions/#{options[:id]}/crashLog", {})
# The log comes back inline as logText, not as a download URL.
text = resp.body.dig("data", "attributes", "logText")
UI.user_error!("No logText in response: #{resp.body.to_s[0, 300]}") unless text
out = File.expand_path("../crashlog-#{options[:id]}.crash", __dir__)
File.write(out, text)
UI.success("Saved to #{out}")
end
desc "Show the App Store state of each version (review status, release type)"
lane :status do
app = connect_api_app
app.get_app_store_versions(includes: "build").each do |v|
UI.message("#{v.version_string}#{v.app_store_state} — release: #{v.release_type} — build: #{v.build&.version}")
end
UI.header("Recent TestFlight builds")
Spaceship::ConnectAPI::Build.all(app_id: app.id, limit: 5, sort: "-uploadedDate").each do |b|
UI.message("build #{b.version}#{b.processing_state} — expired: #{b.expired}#{b.uploaded_date}")
end
end
# For a build that is already on TestFlight: `release` would rebuild it and
# `publish` never submits. Screenshots are left alone — they carry over.
desc "Submit an already-uploaded build for App Store review (automatic release)"
lane :submit do |options|
UI.user_error!("pass build:<number>") unless options[:build]
check_cloudkit_schema(blocking: true)
version = options[:version] || shipped_app_version
ensure_editable_version(version)
upload_to_app_store(
api_key: asc_api_key,
app_version: version,
build_number: options[:build],
skip_binary_upload: true,
skip_metadata: false,
skip_screenshots: true,
overwrite_screenshots: false,
submit_for_review: true,
automatic_release: true,
force: true,
precheck_include_in_app_purchases: false,
run_precheck_before_submit: false
)
end
# Safety net for the Xcode build phase, which only uploads the dSYM produced
# locally. Run it once App Store Connect has finished processing the build —
# `beta` uses skip_waiting_for_build_processing, so right after an upload the
# dSYMs are not there yet and this would download nothing.
desc "Download processed dSYMs from App Store Connect and send them to Crashlytics"
lane :refresh_dsyms do |options|
download_dsyms(
api_key: asc_api_key,
version: options[:version] || "latest",
build_number: options[:build]
)
dsyms = lane_context[SharedValues::DSYM_PATHS] || []
if dsyms.empty?
# Expected for a post-bitcode app: Apple recompiles nothing, so it keeps
# no dSYM of its own. Fall back to the ones the local archive produced.
dsyms = latest_archive_dsyms
UI.important("App Store Connect has no dSYMs for this build; using the latest local archive.")
end
if dsyms.empty?
UI.important("No dSYMs found either — nothing to upload.")
next
end
upload_symbols_to_crashlytics(
gsp_path: "MealMood/Resources/GoogleService-Info.plist",
binary_path: crashlytics_upload_symbols_binary,
dsym_paths: dsyms
)
clean_build_artifacts
end
desc "Upload metadata + localized screenshots for an existing TestFlight build (no rebuild, no submit)"
lane :publish do |options|
version = options[:version] || "1.2.0"
build = options[:build] || "55"
upload_to_app_store(
api_key: asc_api_key,
app_version: version,
build_number: build,
skip_binary_upload: true,
skip_metadata: false,
skip_screenshots: false,
overwrite_screenshots: true,
submit_for_review: false,
automatic_release: false,
precheck_include_in_app_purchases: false,
force: true,
run_precheck_before_submit: false
)
end
desc "Push a new release build to the App Store"
lane :release do
check_cloudkit_schema(blocking: true)
increment_build_number(xcodeproj: "MealMood.xcodeproj")
build_app(
scheme: "MealMood",
sdk: "iphoneos26.5",
export_options: EXPORT_OPTIONS
)
upload_to_app_store(
api_key: asc_api_key,
automatic_release: true,
force: true
)
end
end