mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
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:
1 parent
92b446973f
commit
6ddb3ac43e
23 files changed
+790
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
[email protected]
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
writing,writer,editor,text,document,offline,grammar,typography,epub,pdf,author,manuscript,draft
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://margin.73ai.org
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Margin: The Writing App
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A calm, offline writing studio
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://margin.73ai.org
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PRODUCTIVITY
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Priyanshu
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Jain
|
||||||
@@ -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.
|
||||||
Executable
+189
@@ -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."
|
||||||
Executable
+69
@@ -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."
|
||||||
@@ -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)"
|
||||||
Executable
+122
@@ -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."
|
||||||
Executable
+71
@@ -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."
|
||||||
Executable
+95
@@ -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."
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user