hammer-editor/fastlane/Fastfile
Adam Brown 66826fdf10
Fix Mac App Store publish hanging forever on build processing (#769)
Fix Mac App Store publish hanging forever on build processing

The Mac App Store release job hung indefinitely on "Publish to App Store",
spinning until Actions' 6h default killed the (10x-billed) macOS runner:

    Waiting for App Store Connect to finish processing the new build
    (3.7.0 - 24) for MAC_OS

Root cause: the release lane ran upload_to_testflight with the build-processing
wait enabled. That wait lives in FastlaneCore::BuildWatcher, which polls each
build's processingState until it reads complete. For macOS builds the App Store
Connect API never reliably reports processingState as VALID, so it polls
forever even though the upload succeeded and the build is usable. iOS is
unaffected because its processingState does flip. deliver's select_build only
avoids the same hang when handed an explicit build number (direct lookup); a
nil/"latest" number routes it back into BuildWatcher.

- Fastfile (mac): skip_waiting_for_build_processing on the upload, and always
  submit by an explicitly resolved build number so deliver takes the
  direct-lookup path and never re-enters BuildWatcher. Widen the submit retry
  budget for the release flow. iOS keeps its working wait-based flow.
- Workflows: add timeout-minutes: 120 to both App Store jobs as a hard cost
  backstop, and a desktop_submit dispatch lane on the Mac workflow (mirrors
  iOS) to submit an already-uploaded build without a rebuild.
- Add an Announce Release workflow plus a shared discord-release-message.sh
  script, so a release's Discord message can be posted for a given tag when the
  automated notify job was skipped (e.g. a cancelled run). notify now builds
  its message from the same script, so the two can't drift.
2026-07-21 15:33:17 -07:00

337 lines
12 KiB
Ruby

# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:android)
# Returns an App Store Connect API key hash for use in mac lanes.
# The .p8 is gitignored in desktop/secrets/.
private_lane :appstore_api_key do
app_store_connect_api_key(
key_id: "F8BSZ7LC8Q",
issuer_id: "a49b927f-0f0e-4d4c-85c6-ffddfac9125a",
key_filepath: File.expand_path("../desktop/secrets/AuthKey_F8BSZ7LC8Q.p8", __dir__),
in_house: false,
)
end
# Marketing version (CFBundleShortVersionString / packageVersion). Single source
# of truth is libs.versions.toml `app`; prepareForRelease syncs it into Info.plist.
def app_marketing_version
toml = File.read(File.expand_path("../gradle/libs.versions.toml", __dir__))
toml[/^app\s*=\s*"([^"]+)"/, 1] || UI.user_error!("Could not read `app` version from libs.versions.toml")
end
# Maps deliver's platform param to the App Store Connect API platform enum.
ASC_PLATFORM = { "ios" => "IOS", "osx" => "MAC_OS" }.freeze
# Version states meaning the build was already accepted into review or beyond —
# i.e. the submission landed. Covers both the legacy appStoreState and the newer
# appVersionState vocabularies (Apple is migrating between them). Editable states
# (PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW) and rejections are excluded on purpose.
SUBMITTED_STATES = %w[
WAITING_FOR_REVIEW IN_REVIEW PENDING_APPLE_RELEASE PENDING_DEVELOPER_RELEASE
PROCESSING_FOR_APP_STORE PROCESSING_FOR_DISTRIBUTION READY_FOR_SALE
READY_FOR_DISTRIBUTION ACCEPTED
].freeze
# True if the App Store version for params is already submitted with the intended
# build attached. Requires the spaceship token, which appstore_api_key sets.
# Any lookup failure is non-fatal: return false and let the caller retry/raise.
def submission_landed?(params)
platform = ASC_PLATFORM[params[:platform]] || params[:platform].to_s.upcase
app = Spaceship::ConnectAPI::App.find(params[:app_identifier])
return false unless app
version = app.get_app_store_versions(
filter: { platform: platform, versionString: params[:app_version] },
).find { |v| v.version_string == params[:app_version] }
return false unless version
states = [version.app_store_state, version.app_version_state].compact
return false unless states.any? { |s| SUBMITTED_STATES.include?(s) }
# When a specific build was targeted, only trust the green if that's the one attached.
return version.get_build&.version.to_s == params[:build_number].to_s if params[:build_number]
true
rescue => e
UI.important("Could not verify App Store submission state: #{e.message.to_s.lines.first&.strip}")
false
end
# Run deliver with a bounded retry. Right after upload the build is processed in
# TestFlight but can lag propagating to the App Store version API, so deliver
# crashes with "Could not find build". That crash happens during build lookup —
# before anything is attached or submitted — so retrying the whole call is safe.
# Once the build attaches, App Store Connect is eventually consistent: the submit
# takes effect but the version→build relationship API briefly reports the build
# as unmodifiable, so deliver raises after the submission already landed. Verify
# the real version state before treating that as a failure.
# https://github.com/fastlane/fastlane/issues/9012
def deliver_with_retry(params, attempts: 6, wait_seconds: 60)
try = 0
begin
try += 1
deliver(params)
rescue => e
if submission_landed?(params)
UI.success("App Store version #{params[:app_version]} is already submitted for review — deliver's error was a false negative.")
return
end
raise if try >= attempts
UI.important("deliver attempt #{try}/#{attempts} failed: #{e.message.to_s.lines.first&.strip}")
UI.important("Build may still be propagating to App Store Connect; retrying in #{wait_seconds}s…")
sleep(wait_seconds)
retry
end
end
platform :mac do
desc "Build a sandboxed .pkg and upload to TestFlight only (beta; does not submit for review)."
lane :desktop_testflight do
desktop_build_and_upload
end
desc "Full Mac App Store release: build, upload, submit for review, auto-release on approval."
lane :desktop_release do
build_number = desktop_build_and_upload
desktop_submit(build_number: build_number)
end
desc "Submit an already-uploaded build for review + auto-release. build_number defaults to the latest uploaded."
lane :desktop_submit do |options|
api_key = appstore_api_key
# deliver only skips its build-processing wait when handed an explicit build
# number — it then selects that build directly. A nil/"latest" build number
# sends deliver into FastlaneCore::BuildWatcher, which hangs forever on macOS
# (the App Store Connect API never reports a macOS build's processingState as
# complete). So always resolve a concrete build number ourselves.
build_number = options[:build_number]
if build_number.nil? || build_number.to_s.empty?
build_number = latest_testflight_build_number(
api_key: api_key,
app_identifier: "com.darkrockstudios.apps.hammer",
platform: "osx",
initial_build_number: 0,
).to_s
end
params = {
api_key: api_key,
app_identifier: "com.darkrockstudios.apps.hammer",
platform: "osx",
app_version: app_marketing_version,
build_number: build_number,
skip_binary_upload: true,
skip_screenshots: true,
metadata_path: "fastlane/metadata/osx",
submit_for_review: true,
automatic_release: true,
run_precheck_before_submit: false,
force: true,
}
# In the release flow this runs right after upload, so the build can take a
# few minutes to become queryable/submittable — retry generously. deliver
# returns as soon as it succeeds, and submission_landed? guards false
# negatives; the retry budget stays well under the job's timeout-minutes.
deliver_with_retry(params, attempts: 30)
end
# Build number is the latest in TestFlight + 1 — per-platform, always above
# what's shipped, and self-correcting on re-runs. Set BUILD_NUMBER to override.
# Returns the build number so the release lane can submit that exact build.
private_lane :desktop_build_and_upload do
api_key = appstore_api_key
build_number = ENV["BUILD_NUMBER"]
if build_number.nil? || build_number.empty?
latest = latest_testflight_build_number(
api_key: api_key,
app_identifier: "com.darkrockstudios.apps.hammer",
platform: "osx",
initial_build_number: 0,
)
build_number = (latest.to_i + 1).to_s
end
UI.message("Mac App Store build number: #{build_number}")
sh("cd \"#{File.expand_path('..', __dir__)}\" && ./desktop/scripts/build-appstore.sh #{build_number}")
pkg_path = Dir.glob(File.expand_path("../desktop/build/installers/main-release/pkg/*.pkg", __dir__)).first
UI.user_error!("Build produced no .pkg") unless pkg_path
UI.message("Uploading #{pkg_path}")
# Do NOT wait for App Store Connect to "finish processing" the build: macOS
# builds' processingState never reliably flips to VALID via the App Store
# Connect API, so fastlane's BuildWatcher polls "Waiting for App Store Connect
# to finish processing…" forever even though the upload succeeded and the
# build is usable. desktop_release submits by explicit build number, and
# deliver's select_build looks that up directly (no processing poll) — so
# there is nothing to wait for here.
upload_to_testflight(
api_key: api_key,
app_identifier: "com.darkrockstudios.apps.hammer",
app_platform: "osx",
pkg: pkg_path,
skip_waiting_for_build_processing: true,
)
build_number
end
end
platform :ios do
desc "Build a signed .ipa and upload to TestFlight only (beta; does not submit for review)."
lane :ios_testflight do
ios_build_and_upload(wait: false)
end
desc "Full iOS App Store release: build, upload, wait for processing, submit for review, auto-release on approval."
lane :ios_release do
build_number = ios_build_and_upload(wait: true)
ios_submit(build_number: build_number)
end
desc "Submit an already-processed TestFlight build for review + auto-release. build_number defaults to latest."
lane :ios_submit do |options|
params = {
api_key: appstore_api_key,
app_identifier: "com.darkrockstudios.apps.hammer.ios",
platform: "ios",
app_version: app_marketing_version,
skip_binary_upload: true,
skip_screenshots: true,
metadata_path: "fastlane/metadata/ios",
submit_for_review: true,
automatic_release: true,
run_precheck_before_submit: false,
force: true,
}
params[:build_number] = options[:build_number] if options[:build_number]
deliver_with_retry(params)
end
# Same pattern as :mac — build number is the latest in TestFlight + 1.
# Set BUILD_NUMBER to override. Returns the build number.
private_lane :ios_build_and_upload do |options|
api_key = appstore_api_key
build_number = ENV["BUILD_NUMBER"]
if build_number.nil? || build_number.empty?
latest = latest_testflight_build_number(
api_key: api_key,
app_identifier: "com.darkrockstudios.apps.hammer.ios",
platform: "ios",
initial_build_number: 0,
)
build_number = (latest.to_i + 1).to_s
end
UI.message("iOS App Store build number: #{build_number}")
# Bump CFBundleVersion in Info.plist before archiving. CI runners are
# ephemeral so dirty state doesn't matter; locally, commit or revert
# the resulting Info.plist change after the lane runs.
set_info_plist_value(
path: File.expand_path("../ios/ios/Info.plist", __dir__),
key: "CFBundleVersion",
value: build_number,
)
build_app(
project: File.expand_path("../ios/ios.xcodeproj", __dir__),
scheme: "ios",
configuration: "Release",
output_directory: File.expand_path("../ios/build", __dir__),
output_name: "Hammer.ipa",
export_method: "app-store",
export_options: {
method: "app-store",
teamID: "8P3G3HT4J5",
signingStyle: "manual",
provisioningProfiles: {
"com.darkrockstudios.apps.hammer.ios" => "Hammer AppStore iOS",
},
},
clean: true,
silent: true,
)
# When releasing we must wait for App Store Connect to finish processing the
# build before deliver can attach it to the version and submit it. Unlike
# macOS, iOS builds' processingState reliably completes, so this returns
# normally; the job's timeout-minutes caps any pathological stall without
# cutting off a legitimately slow processing run at a tighter deadline.
upload_to_testflight(
api_key: api_key,
app_identifier: "com.darkrockstudios.apps.hammer.ios",
app_platform: "ios",
ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
skip_waiting_for_build_processing: !options[:wait],
)
build_number
end
end
platform :android do
desc "Runs all the tests"
lane :test do
gradle(
task: "test",
gradle_path: "./gradlew"
)
end
desc "Submit a new Build to Google Play Internal"
lane :internal do
gradle(
task: ":android:bundleRelease",
gradle_path: "./gradlew"
)
upload_to_play_store(track: 'internal')
end
desc "Submit a new Alpha Build to Google Play Close"
lane :alpha do
gradle(
task: ":android:bundleRelease",
gradle_path: "./gradlew"
)
upload_to_play_store(track: 'alpha')
end
desc "Submit a new Beta Build to Google Play Open"
lane :beta do
gradle(
task: ":android:bundleRelease",
gradle_path: "./gradlew"
)
upload_to_play_store(track: 'beta')
end
desc "Deploy a new version to the Google Play"
lane :release do
gradle(
task: "clean bundleRelease",
gradle_path: "./gradlew"
)
upload_to_play_store(
root_url: "https://androidpublisher.googleapis.com/",
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end