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
This commit is contained in:
pj committed 2026-08-31 17:26:14 +05:30
1 parent 92b446973f
commit 6ddb3ac43e
23 files changed
+790

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env ruby
# Push the listing copy in appstore/metadata to App Store Connect.
#
# The copy lives in text files rather than in here so that changing a description is a diff someone
# can read, and so the store listing is reviewable in the same place as the code it describes.
#
# [email protected] ruby scripts/appstore-listing.rb
#
# Run it yourself: the Apple ID login prompts for a two-factor code the first time each month.
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
require "json"
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
LOCALE = "en-US"
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
# This Apple ID can see more than one App Store Connect team, and the wrong one belongs to someone
# else entirely. Pin it rather than letting spaceship pick.
ITC_TEAM_ID = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
ENV["FASTLANE_ITC_TEAM_ID"] = ITC_TEAM_ID
# Apple rejects an over-length field with a validation error that does not name the limit, so the
# check belongs here where the number is visible.
LIMITS = {
"name" => 30, "subtitle" => 30, "keywords" => 100,
"promotional_text" => 170, "description" => 4000,
}.freeze
def field(name, localized: true)
path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt")
return nil unless File.exist?(path)
value = File.read(path).strip
limit = LIMITS[name]
abort "#{name} is #{value.length} characters, over Apple's limit of #{limit}." if limit && value.length > limit
value
end
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app on team #{ITC_TEAM_ID} for #{BUNDLE_ID}. Create it with produce first." unless app
puts "#{app.name} (#{app.id})"
info = app.fetch_edit_app_info
abort "No editable app info; the listing may already be in review." unless info
localization = info.get_app_info_localizations.find { |l| l.locale == LOCALE }
localization ||= info.create_app_info_localization(attributes: { locale: LOCALE })
# The name is the one field a person is likely to change by hand in App Store Connect, and losing
# somebody's naming decision to a stale text file is not a good trade. So divergence is reported
# and the live value kept, rather than overwritten.
attributes = { subtitle: field("subtitle"), privacyPolicyUrl: field("privacy_url") }
wanted_name = field("name")
if wanted_name && localization.name && wanted_name != localization.name
puts " keeping the name set in App Store Connect (#{localization.name.inspect});"
puts " #{DIR}/#{LOCALE}/name.txt says #{wanted_name.inspect}. Update the file to match, or pass"
puts " FORCE_NAME=1 to make the file win."
attributes[:name] = wanted_name if ENV["FORCE_NAME"]
else
attributes[:name] = wanted_name
end
localization.update(attributes: attributes)
puts " subtitle and privacy policy set"
category = field("primary_category", localized: false)
if category
info.update_categories(category_id_map: { primary_category_id: category })
puts " primary category set to #{category}"
end
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
abort "No editable macOS version to write to." unless version
# The store version has to match the CFBundleShortVersionString of the build that will be uploaded
# against it, and that comes from tauri.conf.json like every other version in the repo. Left alone,
# a record created by `produce` sits at 1.0 and rejects the first build with a version mismatch.
app_version = JSON.parse(File.read("src-tauri/tauri.conf.json"))["version"]
if version.version_string != app_version
version.update(attributes: { versionString: app_version })
puts " version corrected from #{version.version_string} to #{app_version}"
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
end
version_localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE }
version_localization ||= version.create_app_store_version_localization(attributes: { locale: LOCALE })
attributes = {
description: field("description"),
keywords: field("keywords"),
promotionalText: field("promotional_text"),
supportUrl: field("support_url"),
marketingUrl: field("marketing_url"),
}
# Release notes describe what changed since the last release, so Apple refuses them on a first
# version and there is nothing truthful to put there anyway.
if app.get_live_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
attributes[:whatsNew] = field("release_notes")
else
puts " skipping release notes: nothing has shipped yet for them to be relative to"
end
version_localization.update(attributes: attributes)
puts " description, keywords and links set on version #{version.version_string}"
copyright = field("copyright", localized: false)
version.update(attributes: { copyright: copyright }) if copyright
puts " copyright set to #{copyright}" if copyright
puts
puts "Screenshots are not set here. They are required to submit for review, but not to upload a"
puts "build or to run either tier of TestFlight."