From b59052a95cb523215e9fa85b3a7de3b14581fc58 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 31 Aug 2026 17:27:59 +0530 Subject: [PATCH] feat(appstore): script inviting TestFlight testers 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. Defaults to the external group. The internal one only accepts people who already have an App Store Connect account on the team, which is a much bigger thing to hand somebody than a build. Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup --- scripts/testflight-testers.rb | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100755 scripts/testflight-testers.rb diff --git a/scripts/testflight-testers.rb b/scripts/testflight-testers.rb new file mode 100755 index 0000000..ee6fe7e --- /dev/null +++ b/scripts/testflight-testers.rb @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby +# Invite people to a TestFlight group. +# +# APPLE_EMAIL=you@example.com ruby scripts/testflight-testers.rb someone@example.com ... +# +# 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 someone@example.com [more@example.com ...]" 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'})" + +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 }], + ) + puts " invited #{email}" +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