Files
margin/scripts/testflight-release.rb
pj 6ddb3ac43e feat(appstore): script the provisioning, listing and TestFlight setup
The listing copy lives in text files rather than in a web form, so changing a description is a diff
someone can read and the store listing is reviewable next to the code it describes. Field lengths
are checked before sending, because Apple rejects an over-length field with an error that never
names the limit.

apple-provision.rb drives the Developer Portal through spaceship and is find-or-create throughout.
That matters most for the Developer ID certificate: an account may hold only a handful, they cannot
be un-revoked, and every copy of the app already signed by one stops verifying if it goes away.

Both scripts pin the App Store Connect team. This Apple ID can see more than one, and the other
belongs to somebody else entirely, so letting spaceship choose is how a listing ends up on the
wrong account.

The reviewer phone number and email are deliberately not in here. Apple requires a real number and
this repo is public.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:26:14 +05:30

96 lines
3.9 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# Put the most recently uploaded build in front of testers.
#
# Apple takes somewhere between a few minutes and an hour to process an upload, and nothing can be
# assigned until it has. So this waits rather than failing, and says what it is waiting for.
#
# [email protected] ruby scripts/testflight-release.rb
#
# Internal testers get the build as soon as it is assigned. External testers are gated on Beta App
# Review, which this submits for and which usually comes back within a day.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
WAIT_SECONDS = Integer(ENV.fetch("WAIT_SECONDS", "1800"))
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
puts "#{app.name} (#{app.id})"
deadline = Time.now + WAIT_SECONDS
build = nil
loop do
builds = Spaceship::ConnectAPI::Build.all(app_id: app.id, sort: "-uploadedDate", limit: 5)
ready = builds.reject(&:expired).find { |b| b.processing_state == "VALID" }
if ready
build = ready
break
end
pending = builds.find { |b| b.processing_state == "PROCESSING" }
if Time.now > deadline
abort "Timed out after #{WAIT_SECONDS}s. #{pending ? 'The build is still processing.' : 'No build has appeared yet.'}"
end
puts(pending ? " waiting: build #{pending.version} is still processing" : " waiting: no build has appeared yet")
sleep(30)
end
puts " build #{build.app_version} (#{build.version}) is ready"
groups = app.get_beta_groups
# A group created with hasAccessToAllBuilds receives every build the moment it processes, and Apple
# rejects an explicit assignment to one rather than treating it as a no-op. The internal group is
# exactly that, so there is nothing to do for it and nothing to report either.
assignable = groups.reject { |g| g.is_internal_group || g.has_access_to_all_builds }
already = (build.get_beta_groups.map(&:id) rescue [])
to_add = assignable.reject { |g| already.include?(g.id) }
if to_add.empty?
puts " nothing to assign: #{groups.map(&:name).join(', ')} already have this build"
else
build.add_beta_groups(beta_groups: to_add)
puts " assigned to #{to_add.map(&:name).join(', ')}"
end
# Only external groups are gated on review, so an app with internal testers only never needs this.
if groups.any? { |g| !g.is_internal_group }
begin
Spaceship::ConnectAPI.post_beta_app_review_submissions(build_id: build.id)
puts " submitted for beta app review, which gates the external testers"
rescue => e
# Resubmitting an already submitted build is not an error worth failing the run over.
puts " beta app review not submitted: #{e.message.lines.first.to_s.strip}"
end
end
# The store version needs the build attached too, and that is a separate thing from TestFlight.
# Until it is, App Store Connect shows the listing with no app icon, because for a Mac app the icon
# is read out of the attached build rather than uploaded alongside the other artwork.
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
if version.nil?
puts " no editable store version to attach the build to"
elsif version.build&.id == build.id
puts " already attached to store version #{version.version_string}"
else
version.select_build(build_id: build.id)
puts " attached to store version #{version.version_string}, which is what surfaces the app icon"
end
puts
puts "Internal testers can install now. External testers wait on beta app review."