Files
eranos/ios/fastlane/Fastfile
T
Alex Gleason b8773c47d7 Automate App Store releases via self-hosted Mac runner
Mirror the existing Android publishing flow for iOS. The pipeline
gains two jobs: build-ipa runs on a self-hosted Mac runner and
produces a signed App Store IPA; publish-app-store runs on a shared
Linux runner and submits the prebuilt IPA to App Store Connect.

Build pipeline (.gitlab-ci.yml):
- build-ipa (Mac, stage build, parallel with build-apk): decodes the
  ASC API key, runs match (with api_key, so cert validity is verified
  against Apple before xcodebuild starts), builds web assets, syncs
  Capacitor, stamps MARKETING_VERSION. Uploads Ditto-${CI_COMMIT_TAG}
  .ipa to GitLab's Generic Packages registry.
- publish-app-store (Linux ruby:3.3, needs: [build-ipa]): gem
  install fastlane, decode the ASC API key, extract the changelog
  section into release_notes.txt, fastlane submit_release with
  IPA_PATH pointing at the inherited artifact. No Xcode, no signing,
  no keychain \u2014 pure Apple API call.
- release job now needs both build-apk and build-ipa, and links three
  assets (APK / AAB / IPA).

fastlane (ios/fastlane/Fastfile, Matchfile, Appfile, metadata/):
- Four lanes: build_ipa (CI build), submit_release (CI publish, reads
  IPA_PATH from env), release (single-step convenience for local
  dev), submit_only (debug lane to re-submit an already-uploaded
  build).
- Match config points at the private gitlab.com/soapbox-pub
  /certificates repo. App Store Connect API key is built inline in
  the Fastfile to avoid a collision with match's APP_STORE_CONNECT
  _API_KEY_PATH env var (match wants a JSON descriptor, the action
  writes a raw .p8). CI overrides CODE_SIGN_STYLE=Manual via xcargs
  so the Xcode project can stay on Automatic for local development.

Vite config (vite.config.ts):
- Renames the build-time config override env var from CONFIG_FILE to
  DITTO_CONFIG_FILE. GitLab Runner sets CONFIG_FILE to its own TOML
  config in job env, which broke vite's loader.

App-side changes:
- ios/App/App.xcodeproj/project.pbxproj: team GZLTTH5DLM stamped in;
  MARKETING_VERSION gets stamped from the tag at build time.
- public/CHANGELOG.md, package.json: v2.14.3.

Skills + AGENTS.md updated to reflect the six-job pipeline (test /
deploy unchanged, build now has two jobs, release / publish updated)
and to document Mac-runner operations, fastlane match cert rotation,
and local debugging workflows.
2026-05-11 12:59:04 -07:00

147 lines
5.4 KiB
Ruby

default_platform(:ios)
platform :ios do
# ─── Lanes ────────────────────────────────────────────────────────────
desc "Build and sign the App Store IPA. Output at ../artifacts/Ditto.ipa."
lane :build_ipa do
setup_lane_signing!
build_release_ipa!
end
desc "Submit an already-built IPA to App Store Connect for review. " \
"Set IPA_PATH to the IPA's location."
lane :submit_release do
ipa_path = ENV.fetch("IPA_PATH") do
UI.user_error!("submit_release requires the IPA_PATH env var")
end
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
submit_release_for_review!(ipa_path)
end
desc "Build, sign, and submit Ditto to the App Store for review (single-step convenience)."
lane :release do
setup_lane_signing!
build_release_ipa!
# Use the IPA path set by build_app rather than recomputing it from
# __dir__, which gets fragile across fastlane-relative paths.
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("build_app did not set IPA_OUTPUT_PATH") unless ipa_path
submit_release_for_review!(ipa_path)
end
desc "Submit an already-uploaded build for review (skip build/upload). " \
"Use BUILD_NUMBER and VERSION env vars."
lane :submit_only do
submit_release_for_review!(nil)
end
# ─── Helpers ──────────────────────────────────────────────────────────
def setup_lane_signing!
# Create an ephemeral keychain so we never touch the login keychain.
setup_ci
api_key = build_api_key!
# Fetch encrypted distribution cert + provisioning profile from the
# shared certificates repo. --readonly: never mutate from CI.
# Passing api_key makes match contact Apple's portal to verify the
# cert is still valid for the team — fails fast on revoked/expired
# certs instead of letting xcodebuild stumble later.
match(type: "appstore", readonly: true, api_key: api_key)
api_key
end
def build_api_key!
# Build the API key hash inline. We avoid the app_store_connect_api_key
# action because it sets APP_STORE_CONNECT_API_KEY_PATH (path to the .p8)
# which collides with match's APP_STORE_CONNECT_API_KEY_PATH (path to a
# JSON descriptor). Same env name, different formats.
@api_key ||= {
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
duration: 1200,
in_house: false,
}
end
def build_release_ipa!
# Stamp build number from CI pipeline ID so every release is monotonically increasing.
increment_build_number(
xcodeproj: "App/App.xcodeproj",
build_number: ENV.fetch("CI_PIPELINE_IID"),
)
# Marketing version is set externally (sed in CI) before this lane runs.
build_app(
project: "App/App.xcodeproj",
scheme: "App",
configuration: "Release",
export_method: "app-store",
output_directory: "../artifacts",
output_name: "Ditto.ipa",
clean: true,
# Override the Xcode project's Automatic signing for this build only.
# Match has already installed the AppStore cert + profile into the
# ephemeral keychain; tell xcodebuild to use them explicitly so it
# doesn't also try to find an iOS Development cert (which we never
# provision in CI).
xcargs: [
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY='Apple Distribution'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore pub.ditto.app'",
"DEVELOPMENT_TEAM=GZLTTH5DLM",
].join(" "),
export_options: {
method: "app-store",
signingStyle: "manual",
teamID: "GZLTTH5DLM",
provisioningProfiles: {
"pub.ditto.app" => "match AppStore pub.ditto.app",
},
},
)
end
# If ipa_path is nil, deliver picks up the latest processed build for the
# configured app version (used by the submit_only lane).
def submit_release_for_review!(ipa_path)
api_key = build_api_key!
options = {
api_key: api_key,
submit_for_review: true,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false,
# Don't try to PATCH content rights on every submit — Apple's API
# rejects updates to contentRightsDeclaration once the listing has
# an established state. The values stay as set in the App Store
# Connect UI / from a prior submission.
submission_information: {
export_compliance_uses_encryption: false,
},
skip_screenshots: true,
# Keep skip_app_version_update=false: deliver needs to PATCH the
# version's whatsNew (release notes) and platform-version metadata
# before submit_for_review will accept the version.
skip_app_version_update: false,
skip_metadata: false,
metadata_path: "./fastlane/metadata",
run_precheck_before_submit: false,
}
options[:ipa] = ipa_path if ipa_path
if ENV["BUILD_NUMBER"]
options[:build_number] = ENV["BUILD_NUMBER"]
options[:skip_binary_upload] = true
end
options[:app_version] = ENV["VERSION"] if ENV["VERSION"]
deliver(**options)
end
end