Files
margin/scripts/testflight-testers.rb
T
pj 585933c8a1 feat(appstore): invite internal testers as scoped account users
An internal tester has to be a user on the App Store Connect account, which is access to the
account rather than to a build. The invitation is narrowed as far as the API allows: one visible
app, provisioning refused, and the Developer role rather than anything that can change the listing.

The bulk assignment endpoint reports success for an address it then declines to add, which is what
happens when the person has not accepted their account invitation yet, so the group is read back
instead of trusted.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 18:00:27 +05:30

105 lines
4.0 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# Invite people to a TestFlight group.
#
# [email protected] ruby scripts/testflight-testers.rb [email protected] ...
#
# Addresses come from the command line rather than a file in the repo, because a list of testers is
# a list of people's email addresses and this repository is public.
#
# GROUP defaults to the external group, since the internal one only accepts people who already have
# an App Store Connect account on the team, which is a far bigger thing to hand out than a build.
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")
GROUP = ENV.fetch("GROUP", "Public Beta")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
emails = ARGV.reject { |a| a.start_with?("-") }
abort "Usage: ruby scripts/testflight-testers.rb [email protected] [[email protected] ...]" if emails.empty?
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
group = app.get_beta_groups.find { |g| g.name == GROUP }
abort "No group named #{GROUP.inspect}." unless group
puts "#{app.name}: #{group.name} (#{group.is_internal_group ? 'internal' : 'external'})"
# An internal tester has to be a user on the App Store Connect account first, which is a far larger
# grant than a build: it is access to the account, not to an app. So the invitation is narrowed as
# far as the API allows, to this one app and with provisioning refused, and the person still has to
# accept it before they can be put in the group.
if group.is_internal_group
known = Spaceship::ConnectAPI::User.all.map { |u| u.email.to_s.downcase }
invited = Spaceship::ConnectAPI::UserInvitation.all.map { |i| i.email.to_s.downcase }
emails.each do |email|
next if known.include?(email.downcase)
if invited.include?(email.downcase)
puts " #{email} has an invitation waiting to be accepted"
next
end
local = email.split("@").first
Spaceship::ConnectAPI::UserInvitation.create(
email: email,
first_name: ENV.fetch("FIRST_NAME", local),
last_name: ENV.fetch("LAST_NAME", "Tester"),
roles: [Spaceship::ConnectAPI::User::UserRole::DEVELOPER],
provisioning_allowed: false,
all_apps_visible: false,
visible_app_ids: [app.id],
)
puts " invited #{email} to App Store Connect, limited to this app, no provisioning access"
invited << email.downcase
end
end
existing = Spaceship::ConnectAPI::BetaTester
.all(filter: { betaGroups: group.id })
.map { |t| t.email.to_s.downcase }
emails.each do |email|
if existing.include?(email.downcase)
puts " #{email} is already in this group"
next
end
Spaceship::ConnectAPI.post_bulk_beta_tester_assignments(
beta_group_id: group.id,
beta_testers: [{ email: email }],
)
# The bulk endpoint reports success for an address it then quietly declines to add, which is what
# happens on an internal group when the person has not accepted their account invitation yet. So
# the group is read back rather than trusted.
landed = Spaceship::ConnectAPI::BetaTester
.all(filter: { betaGroups: group.id })
.any? { |t| t.email.to_s.casecmp?(email) }
if landed
puts " added #{email}"
else
puts " #{email} was not added. An internal tester has to accept the App Store Connect"
puts " invitation first; rerun this once they have."
end
end
puts
if group.is_internal_group
puts "Internal testers can install as soon as a build finishes processing."
else
puts "External testers get the invitation once beta app review passes."
puts "Anyone can also join through #{group.public_link}" if group.public_link
end