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

@@ -0,0 +1 @@
[email protected]
+1
View File
@@ -0,0 +1 @@
2026 Priyanshu Jain
@@ -0,0 +1,5 @@
Margin is an offline writing studio. Write without anything in the way, see your words set in real typography as you go, and export a print-ready PDF or an EPUB when you are ready.
This build runs in the App Store sandbox, which is new, so the things most worth trying are the ones that touch the file system: importing, exporting a PDF or EPUB, and the optional Google Drive backup. If any of those fail where they used to work, that is the bug worth reporting.
There is no account and nothing to sign up for.
+13
View File
@@ -0,0 +1,13 @@
Margin is a calm, offline writing studio. Write without anything in the way, see your words set in real typography as you go, and keep every one of them on your own machine.
WRITE
A quiet editor that stays out of the way, with your work down one side and a live page preview down the other, showing your words as they will actually appear rather than as a word processor imagines them.
PROOF
Spelling and grammar are checked as you write, using the same system engine as the rest of macOS. Your words are never sent anywhere to be checked.
PUBLISH
When you are ready, export a print-ready PDF set in real typography, with proper margins, page numbers and running heads. Or export EPUB, ready for Apple Books, Kindle, Kobo and the rest.
YOURS
No account. No sign-up. No subscription. Your work is a single file on your computer that you can copy, rename, back up, or move to another machine. Nothing is locked inside a library you cannot get out of. If you would like a backup, Margin can save one to your own Google Drive, in your account and under your control.
+1
View File
@@ -0,0 +1 @@
writing,writer,editor,text,document,offline,grammar,typography,epub,pdf,author,manuscript,draft
@@ -0,0 +1 @@
https://margin.73ai.org
+1
View File
@@ -0,0 +1 @@
Margin: The Writing App
+1
View File
@@ -0,0 +1 @@
https://margin.73ai.org/privacy
@@ -0,0 +1 @@
Write without anything in the way. Real typography, spelling and grammar on your own machine, and export to PDF or EPUB when you are ready.
@@ -0,0 +1 @@
First release.
+1
View File
@@ -0,0 +1 @@
A calm, offline writing studio
+1
View File
@@ -0,0 +1 @@
https://margin.73ai.org
+1
View File
@@ -0,0 +1 @@
PRODUCTIVITY
+1
View File
@@ -0,0 +1 @@
Priyanshu
+1
View File
@@ -0,0 +1 @@
Jain
+13
View File
@@ -0,0 +1,13 @@
Margin has no accounts, so no demo credentials are needed. Open the app and start writing.
Two entitlements may look worth questioning, so here is why each is there:
com.apple.security.network.server is for the Google Drive backup. Google's installed-app OAuth flow
redirects to a loopback listener on 127.0.0.1, which is the only flow Google still supports for a
desktop app, and the sandbox refuses to bind that socket without this entitlement. Nothing listens
on a routable interface and nothing accepts a connection from another machine.
com.apple.security.network.client is used only to reach googleapis.com for that same backup. The
backup is optional and off until the user connects their own Google account. Nothing else the app
does uses the network: writing, the page preview, spelling, grammar, and every export run entirely
on the machine.
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env ruby
# Create the Apple Developer resources a release needs, and turn them into files CI can use.
#
# Everything here is find-or-create, so running it twice does nothing the second time. 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.
#
# The private keys are never sent to Apple and never leave ~/.margin-signing. Apple only ever sees
# the certificate signing requests, which is the whole point of generating them with openssl up
# front rather than letting a tool make its own.
#
# BUNDLE_ID=studio.margin.app APP_NAME=Margin ruby scripts/apple-provision.rb
#
# Run it yourself rather than through an agent: the Apple ID password and the two-factor code are
# prompted for on the terminal.
begin
require "spaceship"
rescue LoadError
# Homebrew vendors fastlane's gems under libexec instead of putting them on the default gem
# path, so spaceship is not requirable until that directory is added to it.
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 "openssl"
require "fileutils"
require "securerandom"
require "net/http"
require "tmpdir"
# Listing profiles otherwise goes through developerservices2.apple.com, Apple's Xcode-only
# endpoint, which rejects a plain spaceship session with "Please update to Xcode 7.3 or later"
# no matter how current Xcode actually is. This routes it back to the ordinary portal API.
ENV["SPACESHIP_AVOID_XCODE_API"] = "1"
DIR = File.expand_path("~/.margin-signing")
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
APP_NAME = ENV.fetch("APP_NAME", "Margin")
EMAIL = ENV["APPLE_EMAIL"]
# Apple's intermediates. codesign builds a chain from the leaf up, so a .p12 holding only the leaf
# and its key fails on a fresh CI keychain with "unable to build chain to self-signed root".
INTERMEDIATES = {
"AppleWWDRCAG3" => "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer",
"DeveloperIDG2CA" => "https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer",
}
CERTS = [
{ key: "developer-id", klass: Spaceship::Portal::Certificate::DeveloperIdApplication,
label: "Developer ID Application (direct download, notarized)", ca: "DeveloperIDG2CA" },
{ key: "apple-distribution", klass: Spaceship::Portal::Certificate::MacAppDistribution,
label: "Mac App Distribution (App Store .app)", ca: "AppleWWDRCAG3" },
{ key: "mac-installer", klass: Spaceship::Portal::Certificate::MacInstallerDistribution,
label: "Mac Installer Distribution (App Store .pkg)", ca: "AppleWWDRCAG3" },
]
def common_name(cert)
cert.subject.to_a.find { |n, _, _| n == "CN" }&.at(1)
end
def fetch_intermediate(name)
path = File.join(DIR, "#{name}.cer")
unless File.exist?(path)
uri = URI(INTERMEDIATES.fetch(name))
File.binwrite(path, Net::HTTP.get(uri))
end
OpenSSL::X509::Certificate.new(File.binread(path))
end
# Confirm macOS can actually read the bundle, because the failure mode otherwise shows up days
# later inside a CI keychain as "wrong password" rather than as anything about the format.
def importable?(p12_path, password)
keychain = File.join(Dir.tmpdir, "margin-p12-check-#{SecureRandom.hex(4)}.keychain-db")
system("security", "create-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL)
system("security", "unlock-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL)
ok = system("security", "import", p12_path, "-k", keychain, "-P", password,
out: File::NULL, err: File::NULL)
system("security", "delete-keychain", keychain, out: File::NULL, err: File::NULL)
ok
end
# Returns nil on success, or a sentence saying what went wrong.
def write_p12(spec, cert)
key_path = File.join(DIR, "#{spec[:key]}.key")
unless cert.check_private_key(OpenSSL::PKey::RSA.new(File.read(key_path)))
return "the issued certificate does not match the local private key, so Apple issued it " \
"against a different CSR. Revoke it in the portal and rerun."
end
password = SecureRandom.hex(24)
p12_path = File.join(DIR, "#{spec[:key]}.p12")
built = Dir.mktmpdir do |tmp|
leaf = File.join(tmp, "leaf.pem")
ca = File.join(tmp, "ca.pem")
File.write(leaf, cert.to_pem)
File.write(ca, fetch_intermediate(spec[:ca]).to_pem)
# Ruby links OpenSSL 3, whose PKCS12 default MAC is SHA-256. Apple's Security framework reads
# only the legacy SHA-1 MAC and reports the mismatch as a wrong password, so the bundle has to
# come from the LibreSSL at /usr/bin/openssl, which still writes the older format. The password
# goes through the environment rather than argv so it stays out of the process list.
system({ "P12PASS" => password }, "/usr/bin/openssl", "pkcs12", "-export",
"-inkey", key_path, "-in", leaf, "-certfile", ca,
"-name", common_name(cert), "-passout", "env:P12PASS", "-out", p12_path,
out: File::NULL, err: File::NULL)
end
return "/usr/bin/openssl could not build the bundle." unless built
return "macOS refused to import the bundle that was just built." unless importable?(p12_path, password)
File.write(File.join(DIR, "#{spec[:key]}.p12.pass"), password)
File.chmod(0o600, p12_path, File.join(DIR, "#{spec[:key]}.p12.pass"))
nil
end
FileUtils.mkdir_p(DIR)
Spaceship::Portal.login(EMAIL)
Spaceship::Portal.select_team
team_id = Spaceship::Portal.client.team_id
puts "Team ID: #{team_id}"
puts
identities = {}
CERTS.each do |spec|
existing = spec[:klass].all.select { |c| c.status == "Issued" }
cert_obj = existing.first
if cert_obj
puts "#{spec[:label]}: already exists (#{cert_obj.id}), not creating another."
else
csr = File.read(File.join(DIR, "#{spec[:key]}.csr"))
cert_obj = spec[:klass].create!(csr: csr)
puts "#{spec[:label]}: created (#{cert_obj.id})."
end
x509 = cert_obj.download
File.binwrite(File.join(DIR, "#{spec[:key]}.cer"), x509.to_der)
identities[spec[:key]] = common_name(x509)
puts " identity: #{common_name(x509)}"
problem = write_p12(spec, x509)
puts(problem ? " no p12: #{problem}" : " wrote #{spec[:key]}.p12")
puts
end
app = Spaceship::Portal::App.find(BUNDLE_ID, mac: true)
if app
puts "App ID #{BUNDLE_ID}: already registered."
else
app = Spaceship::Portal::App.create!(bundle_id: BUNDLE_ID, name: APP_NAME, mac: true)
puts "App ID #{BUNDLE_ID}: registered."
end
profile_name = "#{APP_NAME} App Store"
profile = Spaceship::Portal::ProvisioningProfile::AppStore.all(mac: true).find do |p|
p.app.bundle_id == BUNDLE_ID && p.status == "Active"
end
if profile
puts "Provisioning profile: reusing #{profile.name}."
else
mas_cert = Spaceship::Portal::Certificate::MacAppDistribution.all.first
profile = Spaceship::Portal::ProvisioningProfile::AppStore.create!(
name: profile_name, bundle_id: BUNDLE_ID, certificate: mas_cert, mac: true
)
puts "Provisioning profile: created #{profile.name}."
end
profile_path = File.join(DIR, "#{BUNDLE_ID}.provisionprofile")
File.binwrite(profile_path, profile.download)
File.chmod(0o600, profile_path)
File.write(File.join(DIR, "#{BUNDLE_ID}.env"), <<~ENV)
APPLE_TEAM_ID="#{team_id}"
APPLE_SIGNING_IDENTITY="#{identities['developer-id']}"
MAS_APP_IDENTITY="#{identities['apple-distribution']}"
MAS_INSTALLER_IDENTITY="#{identities['mac-installer']}"
ENV
puts
puts "Wrote #{profile_path} and #{BUNDLE_ID}.env into #{DIR}."
puts "Still to do by hand, because Apple has no API for it: create an App Store Connect API key"
puts "(Users and Access, Integrations) and save the .p8 as #{DIR}/AuthKey.p8."
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Push everything apple-provision.rb produced into this repo's GitHub Actions secrets.
#
# Values are piped from the files straight into `gh`, never echoed, so running this in a shared
# terminal or through an agent does not leak a signing key into the scrollback.
set -euo pipefail
DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}"
BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}"
REPO="${REPO:-priyanshujain/margin}"
ENV_FILE="$DIR/$BUNDLE_ID.env"
[ -f "$ENV_FILE" ] || { echo "apple-secrets: $ENV_FILE is missing; run apple-provision.rb first." >&2; exit 1; }
# shellcheck source=/dev/null
set -a; . "$ENV_FILE"; set +a
set_plain() {
printf '%s' "$2" | gh secret set "$1" --repo "$REPO" --body -
echo " set $1"
}
set_b64() {
[ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; }
base64 -i "$2" | tr -d '\n' | gh secret set "$1" --repo "$REPO" --body -
echo " set $1"
}
set_file() {
[ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; }
gh secret set "$1" --repo "$REPO" < "$2"
echo " set $1"
}
echo "Signing identities and team:"
set_plain APPLE_TEAM_ID "$APPLE_TEAM_ID"
set_plain APPLE_SIGNING_IDENTITY "$APPLE_SIGNING_IDENTITY"
set_plain MAS_APP_IDENTITY "$MAS_APP_IDENTITY"
set_plain MAS_INSTALLER_IDENTITY "$MAS_INSTALLER_IDENTITY"
echo "Certificates:"
set_b64 APPLE_CERTIFICATE "$DIR/developer-id.p12"
set_file APPLE_CERTIFICATE_PASSWORD "$DIR/developer-id.p12.pass"
set_b64 MAS_APP_CERTIFICATE "$DIR/apple-distribution.p12"
set_file MAS_APP_CERTIFICATE_PASSWORD "$DIR/apple-distribution.p12.pass"
set_b64 MAS_INSTALLER_CERTIFICATE "$DIR/mac-installer.p12"
set_file MAS_INSTALLER_CERTIFICATE_PASSWORD "$DIR/mac-installer.p12.pass"
set_b64 MAS_PROVISION_PROFILE "$DIR/$BUNDLE_ID.provisionprofile"
echo "App Store Connect API key:"
if [ -f "$DIR/AuthKey.p8" ] && [ -f "$DIR/AuthKey.env" ]; then
# shellcheck source=/dev/null
set -a; . "$DIR/AuthKey.env"; set +a
# An empty value here would be accepted by `gh` and then fail notarization as an auth error that
# says nothing about a missing issuer, so refuse it at the point the mistake is still visible.
if [ -z "${APPLE_API_KEY_ID:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
echo " refused: AuthKey.env is missing APPLE_API_KEY_ID or APPLE_API_ISSUER." >&2
exit 1
fi
set_plain APPLE_API_KEY_ID "$APPLE_API_KEY_ID"
set_plain APPLE_API_ISSUER "$APPLE_API_ISSUER"
set_b64 APPLE_API_KEY_P8 "$DIR/AuthKey.p8"
else
echo " skipped: put the .p8 at $DIR/AuthKey.p8 and write $DIR/AuthKey.env with"
echo " APPLE_API_KEY_ID= and APPLE_API_ISSUER=, then rerun."
fi
echo
echo "Not handled here: HOMEBREW_TAP_DEPLOY_KEY, an SSH deploy key on the tap rather than"
echo "anything Apple issued."
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env ruby
# Declare the age rating and set pricing. These are the two things App Store Connect will not let a
# submission through without, and neither has anything to do with the copy in appstore-listing.rb.
#
# [email protected] ruby scripts/appstore-compliance.rb
#
# Every content answer here is NONE because Margin is an editor for words the person using it wrote
# themselves. It ships no media, has no feed, no other users, and no in-app browser. If any of that
# ever stops being true, this file is the thing that has to change with it.
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")
NONE = Spaceship::ConnectAPI::AgeRatingDeclaration::Rating::NONE
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})"
info = app.fetch_edit_app_info
abort "No editable app info." unless info
declaration = info.fetch_age_rating_declaration
abort "No age rating declaration to write to." unless declaration
graded = {
alcoholTobaccoOrDrugUseOrReferences: NONE,
contests: NONE,
gamblingSimulated: NONE,
gunsOrOtherWeapons: NONE,
horrorOrFearThemes: NONE,
matureOrSuggestiveThemes: NONE,
medicalOrTreatmentInformation: NONE,
profanityOrCrudeHumor: NONE,
sexualContentGraphicAndNudity: NONE,
sexualContentOrNudity: NONE,
violenceCartoonOrFantasy: NONE,
violenceRealistic: NONE,
violenceRealisticProlongedGraphicOrSadistic: NONE,
}
# The app opens links in the system browser rather than rendering the web itself, and the only
# content it ever shows is the person's own writing, so there is no unrestricted web access and no
# user generated content in the sense Apple means: content from other people.
boolean = {
advertising: false,
ageAssurance: false,
gambling: false,
healthOrWellnessTopics: false,
lootBox: false,
messagingAndChat: false,
parentalControls: false,
unrestrictedWebAccess: false,
userGeneratedContent: false,
}
declaration.update(attributes: graded.merge(boolean))
puts " age rating declared, every content question answered NONE"
# App Privacy. Margin has no telemetry, no accounts and no server of its own, so nothing is
# collected. The Google Drive backup is the one thing that sends bytes anywhere, and it sends them
# to the account of the person who turned it on, which is not the developer collecting anything.
usages = Spaceship::ConnectAPI::AppDataUsage.all(
app_id: app.id, includes: "category,grouping,purpose,dataProtection"
)
if usages.any?(&:is_not_collected?)
puts " privacy already declared as data not collected"
else
Spaceship::ConnectAPI::AppDataUsage.create(app_id: app.id, app_data_usage_protection_id: "DATA_NOT_COLLECTED")
puts " privacy declared: data not collected"
end
state = Spaceship::ConnectAPI::AppDataUsagesPublishState.get(app_id: app.id)
if state.published
puts " privacy declaration already published"
else
state.publish!
puts " privacy declaration published"
end
# Free, everywhere. Territory availability is left alone: the default is all of them.
app.update(attributes: { pricing: [] }) if ENV["SET_PRICING"]
puts " pricing left as configured (the app is free, which is the default for a new record)"
+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."
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env ruby
# Upload the rendered frames in appstore/screenshots to the App Store listing.
#
# [email protected] ruby scripts/appstore-screenshots.rb
#
# The frames are rendered by the HTML under appstore/screenshots/src, so this only ever moves
# finished PNGs. Rerunning replaces what is there rather than appending, because a listing that
# quietly accumulated ten frames across five runs would be worse than one that is simply current.
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 "shellwords"
DIR = ENV.fetch("SCREENSHOT_DIR", "appstore/screenshots")
LOCALE = "en-US"
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
# The only size App Store Connect takes for a Mac app, out of the four it documents, that this
# pipeline renders. Anything else is a mistake worth stopping for.
EXPECTED = [2560, 1600].freeze
frames = Dir[File.join(DIR, "frame-*.png")].sort
abort "No frames in #{DIR}." if frames.empty?
frames.each do |path|
dimensions = `sips -g pixelWidth -g pixelHeight #{path.shellescape} 2>/dev/null`
.scan(/pixel(?:Width|Height):\s*(\d+)/).flatten.map(&:to_i)
next if dimensions == EXPECTED
abort "#{path} is #{dimensions.join('x')}, and Apple wants #{EXPECTED.join('x')}."
end
puts "#{frames.size} frames, all #{EXPECTED.join('x')}"
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})"
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
abort "No editable macOS version." unless version
localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE }
abort "No #{LOCALE} localization; run appstore-listing.rb first." unless localization
display_type = Spaceship::ConnectAPI::AppScreenshotSet::DisplayType::APP_DESKTOP
set = localization.get_app_screenshot_sets.find { |s| s.screenshot_display_type == display_type }
if set
set.app_screenshots.each(&:delete!)
puts " cleared #{set.app_screenshots.size} existing screenshots"
else
set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type })
puts " created the desktop screenshot set"
end
frames.each_with_index do |path, index|
set.upload_screenshot(path: path, position: index)
puts " uploaded #{File.basename(path)}"
end
puts
puts "Apple processes each image before it counts as attached; give it a minute before checking."
+95
View File
@@ -0,0 +1,95 @@
#!/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."
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env ruby
# Set up TestFlight for the app: the tester-facing blurb, the details Beta App Review asks for, and
# the two groups. None of this needs a build to exist, so it can all be in place before the first
# upload and the build then only has to be assigned to a group.
#
# [email protected] ruby scripts/testflight-setup.rb
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
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
LOCALE = "en-US"
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
def field(name, localized: true)
path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt")
File.exist?(path) ? File.read(path).strip : nil
end
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})"
localization = app.get_beta_app_localizations.find { |l| l.locale == LOCALE }
attributes = {
description: field("beta_description"),
feedbackEmail: field("beta_feedback_email", localized: false),
marketingUrl: field("marketing_url"),
privacyPolicyUrl: field("privacy_url"),
}
if localization
Spaceship::ConnectAPI.patch_beta_app_localizations(localization_id: localization.id, attributes: attributes)
else
Spaceship::ConnectAPI.post_beta_app_localizations(app_id: app.id, attributes: attributes.merge(locale: LOCALE))
end
puts " tester blurb and feedback address set"
# Apple requires a contact phone number here, and this only gates external testing: internal
# testers never go through Beta App Review. So a missing number is a warning, not a failure.
if field("review_phone", localized: false)
Spaceship::ConnectAPI.patch_beta_app_review_detail(app_id: app.id, attributes: {
contactFirstName: field("review_first_name", localized: false),
contactLastName: field("review_last_name", localized: false),
contactEmail: field("review_email", localized: false),
contactPhone: field("review_phone", localized: false),
# Margin has no accounts at all, so there is nothing for a reviewer to sign in to. Saying so
# explicitly is what stops the review coming back asking for credentials.
demoAccountRequired: false,
notes: field("review_notes", localized: false),
})
puts " beta app review contact and notes set"
else
puts " skipping beta app review details: #{DIR}/review_phone.txt is missing and Apple requires"
puts " a contact number. Internal testing works without it; external testing does not."
end
existing = app.get_beta_groups.map(&:name)
[
{ name: "Internal", internal: true, public_link: false },
{ name: "Public Beta", internal: false, public_link: true },
].each do |group|
if existing.include?(group[:name])
puts " group #{group[:name].inspect} already exists"
next
end
if group[:internal]
# spaceship always sends the public-link attributes, and App Store Connect rejects them
# outright on an internal group rather than ignoring them, so this one is posted by hand.
body = {
data: {
attributes: { name: group[:name], isInternalGroup: true, hasAccessToAllBuilds: true },
relationships: { app: { data: { id: app.id, type: "apps" } } },
type: "betaGroups",
},
}
Spaceship::ConnectAPI.client.test_flight_request_client.post("v1/betaGroups", body)
puts " created internal group #{group[:name].inspect}"
else
created = app.create_beta_group(
group_name: group[:name],
is_internal_group: false,
public_link_enabled: true,
public_link_limit_enabled: true,
)
puts " created external group #{created.name.inspect}"
end
end
puts
app.get_beta_groups.each do |g|
kind = g.is_internal_group ? "internal" : "external"
puts " #{g.name} (#{kind})#{g.public_link ? " #{g.public_link}" : ''}"
end