mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-08-05 15:49:46 +00:00
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.
337 lines
12 KiB
Ruby
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
|