Files
workouts/Scripts/asc-submit.swift
T
rzen 85d0eaddbb Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store +
WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents
architecture:

- iCloud Drive JSON documents are the sole source of truth (one file per
  aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a
  rebuildable SwiftData cache populated only by an NSMetadataQuery observer
  and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate.
- Shared model layer (ULID, Codable *Documents + stateless mappers, @Model
  cache entities, SwiftData container) compiled into both targets.
- New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone
  is the sole writer of iCloud Drive, the watch round-trips documents.
- AppServices DI + iCloud-required root gate; Swift 6 strict concurrency.
- Starter splits generated on demand from the bundled YAML catalogs.
- Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments
  entitlement (drop CloudKit/App Group/aps-environment).
- Duration stored as Int seconds (was a Date epoch hack); fix workout
  end-on-create, undismissable delete dialog, toolbar-hiding nav stacks,
  and the Settings placeholder.
- Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the
  Scripts/ TestFlight pipeline (release.sh + ASC API scripts).

MARKETING_VERSION 2.0.
2026-06-19 14:25:27 -04:00

281 lines
15 KiB
Swift
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env swift
//
// asc-submit.swift submit an uploaded build for App Store review via the
// App Store Connect API. Picks up where Scripts/release.sh leaves off.
//
// Usage: source .env.release && swift Scripts/asc-submit.swift [options]
//
// Options:
// --version <X> Marketing version to submit (default: CFBundleShort-
// VersionString from Workouts/Resources/Info-iOS.plist).
// --build <N> Build number (CFBundleVersion) to attach (default: the
// most recent build uploaded for the app).
// --bundle <id> Bundle identifier (default: dev.rzen.indie.Workouts).
// --submit Actually submit for review. Without it the script
// STAGES everything (version, build, submission, item)
// and stops short of the final submit so you can review
// in App Store Connect first.
// --wait-seconds N How long to wait for the build to finish processing
// (default: 1200 = 20 min). Polls every 30s.
//
// What it does, in order:
// 1. find the app GET /apps?filter[bundleId]
// 2. wait for the build to be VALID GET /builds?filter[app]&filter[version]
// 3. declare export compliance if unset PATCH /builds/{id}
// 4. find or create the version POST /appStoreVersions
// 5. attach the build to the version PATCH /appStoreVersions/{id}/relationships/build
// 6. create/reuse review submission POST /reviewSubmissions
// + add the version as an item POST /reviewSubmissionItems
// 7. submit (only with --submit) PATCH /reviewSubmissions/{id} {submitted:true}
//
// Reads ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment.
// Metadata (description, keywords, screenshots, age rating, privacy, pricing)
// is NOT set here if any required field is missing, step 7's response lists
// exactly what App Store Connect is waiting on.
//
import Foundation
import CryptoKit
func die(_ msg: String) -> Never {
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
exit(1)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// ---- arguments --------------------------------------------------------------
var bundleID = "dev.rzen.indie.Workouts"
var versionArg: String?
var buildArg: String?
var doSubmit = false
var waitSeconds = 1200
do {
let args = Array(CommandLine.arguments.dropFirst())
var i = 0
func value(_ flag: String) -> String {
i += 1
guard i < args.count else { die("\(flag) needs a value") }
return args[i]
}
while i < args.count {
switch args[i] {
case "--bundle": bundleID = value("--bundle")
case "--version": versionArg = value("--version")
case "--build": buildArg = value("--build")
case "--submit": doSubmit = true
case "--wait-seconds": waitSeconds = Int(value("--wait-seconds")) ?? waitSeconds
case "--help", "-h":
print("usage: swift Scripts/asc-submit.swift [--version X] [--build N] [--bundle id] [--submit] [--wait-seconds N]")
exit(0)
default: die("unknown argument: \(args[i])")
}
i += 1
}
}
// Default marketing version comes from the iOS app's Info.plist (run from repo root).
let versionString: String = versionArg ?? {
let path = "Workouts/Resources/Info-iOS.plist"
guard let data = FileManager.default.contents(atPath: path),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
let v = plist["CFBundleShortVersionString"] as? String else {
die("could not read CFBundleShortVersionString from \(path); pass --version")
}
return v
}()
// ---- ES256 JWT (minted fresh per request so the build-wait poll can't outlast it)
let env = ProcessInfo.processInfo.environment
guard let keyID = env["ASC_KEY_ID"], let issuer = env["ASC_ISSUER_ID"], let keyPath = env["ASC_KEY_PATH"] else {
die("missing ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (source .env.release first)")
}
guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8) else { die("cannot read key at \(keyPath)") }
guard let signingKey = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("could not parse EC key at \(keyPath)") }
func token() -> String {
let now = Int(Date().timeIntervalSince1970)
let header = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\",\"typ\":\"JWT\"}"
let payload = "{\"iss\":\"\(issuer)\",\"iat\":\(now),\"exp\":\(now + 1200),\"aud\":\"appstoreconnect-v1\"}"
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))
guard let sig = try? signingKey.signature(for: Data(signingInput.utf8)) else { die("signing failed") }
return signingInput + "." + b64url(sig.rawRepresentation)
}
// ---- API plumbing -----------------------------------------------------------
let api = "https://api.appstoreconnect.apple.com/v1"
@discardableResult
func request(_ method: String, _ urlStr: String, body: Data? = nil) -> (Int, Data) {
guard let url = URL(string: urlStr) else { die("bad url: \(urlStr)") }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token())", forHTTPHeaderField: "Authorization")
if let body {
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let sem = DispatchSemaphore(value: 0)
var status = 0
var data = Data()
URLSession.shared.dataTask(with: req) { d, resp, err in
if let err { die("network error: \(err)") }
status = (resp as? HTTPURLResponse)?.statusCode ?? 0
data = d ?? Data()
sem.signal()
}.resume()
sem.wait()
return (status, data)
}
func parse(_ data: Data) -> [String: Any]? { try? JSONSerialization.jsonObject(with: data) as? [String: Any] }
func bodyText(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" }
func dataArray(_ data: Data) -> [[String: Any]] { (parse(data)?["data"] as? [[String: Any]]) ?? [] }
func dataObject(_ data: Data) -> [String: Any]? { parse(data)?["data"] as? [String: Any] }
func attrs(_ obj: [String: Any]) -> [String: Any] { (obj["attributes"] as? [String: Any]) ?? [:] }
// ---- 1. find the app --------------------------------------------------------
let (appStatus, appData) = request("GET", "\(api)/apps?filter%5BbundleId%5D=\(bundleID)&fields%5Bapps%5D=bundleId,name")
guard appStatus == 200, let app = dataArray(appData).first, let appID = app["id"] as? String else {
die("app not found for bundle \(bundleID) (status \(appStatus)): \(bodyText(appData))")
}
print("App: \(bundleID) (\(appID))")
print("Target: version \(versionString)\(buildArg.map { " • build \($0)" } ?? " • latest build")\(doSubmit ? " [WILL SUBMIT]" : " [stage only]")")
// ---- 2. resolve + wait for the build ---------------------------------------
// If no build was named, lock onto the most recently uploaded one, then poll
// that specific build version until it finishes processing.
var buildVersion = buildArg
if buildVersion == nil {
let (s, d) = request("GET", "\(api)/builds?filter%5Bapp%5D=\(appID)&sort=-uploadedDate&fields%5Bbuilds%5D=version&limit=1")
guard s == 200, let b = dataArray(d).first, let v = attrs(b)["version"] as? String else {
die("no builds found for \(bundleID); upload one first (Scripts/release.sh)")
}
buildVersion = v
}
let targetBuild = buildVersion!
var buildID: String?
let deadline = Date().addingTimeInterval(TimeInterval(waitSeconds))
while true {
let (s, d) = request("GET", "\(api)/builds?filter%5Bapp%5D=\(appID)&filter%5Bversion%5D=\(targetBuild)&fields%5Bbuilds%5D=version,processingState,usesNonExemptEncryption&limit=1")
guard s == 200 else { die("could not query builds (status \(s)): \(bodyText(d))") }
if let b = dataArray(d).first, let id = b["id"] as? String {
let a = attrs(b)
switch a["processingState"] as? String ?? "?" {
case "VALID":
buildID = id
// ---- 3. declare export compliance if the upload didn't carry it
if (a["usesNonExemptEncryption"] ?? NSNull()) is NSNull {
let body = "{\"data\":{\"type\":\"builds\",\"id\":\"\(id)\",\"attributes\":{\"usesNonExemptEncryption\":false}}}".data(using: .utf8)!
let (ps, pd) = request("PATCH", "\(api)/builds/\(id)", body: body)
guard ps == 200 else { die("could not set export compliance (status \(ps)): \(bodyText(pd))") }
print("✅ export compliance declared (usesNonExemptEncryption = NO)")
}
case "INVALID":
die("build \(targetBuild) is INVALID (processing failed) — check App Store Connect")
case let state:
print("⏳ build \(targetBuild): \(state); waiting…")
}
} else {
print("⏳ build \(targetBuild) not visible yet; waiting…")
}
if buildID != nil { break }
if Date() >= deadline { die("timed out after \(waitSeconds)s waiting for build \(targetBuild) to become VALID") }
Thread.sleep(forTimeInterval: 30)
}
print("Build VALID: \(targetBuild) (\(buildID!))")
// ---- 4. find or create the App Store version --------------------------------
let (vStatus, vData) = request("GET", "\(api)/apps/\(appID)/appStoreVersions?filter%5Bplatform%5D=IOS&filter%5BversionString%5D=\(versionString)&fields%5BappStoreVersions%5D=versionString,platform&limit=1")
guard vStatus == 200 else { die("could not query versions (status \(vStatus)): \(bodyText(vData))") }
var versionID = dataArray(vData).first?["id"] as? String
if let id = versionID {
print("✏️ reusing App Store version \(versionString) (\(id))")
} else {
let body = "{\"data\":{\"type\":\"appStoreVersions\",\"attributes\":{\"platform\":\"IOS\",\"versionString\":\"\(versionString)\"},\"relationships\":{\"app\":{\"data\":{\"type\":\"apps\",\"id\":\"\(appID)\"}}}}}".data(using: .utf8)!
let (s, d) = request("POST", "\(api)/appStoreVersions", body: body)
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create App Store version \(versionString) (status \(s)): \(bodyText(d))")
}
versionID = id
print("🆕 created App Store version \(versionString) (\(id))")
}
// ---- 5. attach the build to the version -------------------------------------
let attachBody = "{\"data\":{\"type\":\"builds\",\"id\":\"\(buildID!)\"}}".data(using: .utf8)!
let (atStatus, atData) = request("PATCH", "\(api)/appStoreVersions/\(versionID!)/relationships/build", body: attachBody)
guard atStatus == 204 || atStatus == 200 else { die("could not attach build to version (status \(atStatus)): \(bodyText(atData))") }
print("🔗 build attached to version")
// ---- 6. find or create the review submission, then add the version ----------
let inProgress = ["WAITING_FOR_REVIEW", "WAITING_FOR_RELEASE", "IN_REVIEW", "UNRESOLVED_ISSUES", "COMPLETING", "CANCELING"]
let (subStatus, subData) = request("GET", "\(api)/reviewSubmissions?filter%5Bapp%5D=\(appID)&filter%5Bplatform%5D=IOS&fields%5BreviewSubmissions%5D=state,platform&limit=50")
guard subStatus == 200 else { die("could not list review submissions (status \(subStatus)): \(bodyText(subData))") }
let submissions = dataArray(subData)
var submissionID = submissions.first(where: { attrs($0)["state"] as? String == "READY_FOR_REVIEW" })?["id"] as? String
if let id = submissionID {
print("✏️ reusing open review submission (\(id))")
} else {
if let busy = submissions.first(where: { inProgress.contains(attrs($0)["state"] as? String ?? "") }) {
die("a review submission is already in progress (state=\(attrs(busy)["state"] as? String ?? "?")); resolve or wait for it before starting another")
}
let body = "{\"data\":{\"type\":\"reviewSubmissions\",\"attributes\":{\"platform\":\"IOS\"},\"relationships\":{\"app\":{\"data\":{\"type\":\"apps\",\"id\":\"\(appID)\"}}}}}".data(using: .utf8)!
let (s, d) = request("POST", "\(api)/reviewSubmissions", body: body)
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create review submission (status \(s)): \(bodyText(d))")
}
submissionID = id
print("🆕 created review submission (\(id))")
}
// Add the version as an item, unless it's already on the submission.
// `fields` must request appStoreVersion or the relationship is omitted from
// the response and the already-added check can never match.
let (itStatus, itData) = request("GET", "\(api)/reviewSubmissions/\(submissionID!)/items?fields%5BreviewSubmissionItems%5D=state,appStoreVersion&limit=50")
let alreadyAdded = itStatus == 200 && dataArray(itData).contains { item in
((item["relationships"] as? [String: Any])?["appStoreVersion"] as? [String: Any])
.flatMap { $0["data"] as? [String: Any] }?["id"] as? String == versionID
}
if alreadyAdded {
print("✔︎ version already on the submission")
} else {
let body = "{\"data\":{\"type\":\"reviewSubmissionItems\",\"relationships\":{\"reviewSubmission\":{\"data\":{\"type\":\"reviewSubmissions\",\"id\":\"\(submissionID!)\"}},\"appStoreVersion\":{\"data\":{\"type\":\"appStoreVersions\",\"id\":\"\(versionID!)\"}}}}}".data(using: .utf8)!
let (s, d) = request("POST", "\(api)/reviewSubmissionItems", body: body)
if s == 409, bodyText(d).contains("already added to this reviewSubmission") {
// Belt and braces: the API is the authority on duplicates.
print("✔︎ version already on the submission")
} else {
guard s == 201 || s == 200 else { die("could not add version to submission (status \(s)): \(bodyText(d))") }
print(" version added to the submission")
}
}
// ---- 7. submit (guarded) ----------------------------------------------------
guard doSubmit else {
print("")
print("🅿️ Staged, not submitted. The review submission is READY_FOR_REVIEW.")
print(" Finish any metadata in App Store Connect, then submit there, or re-run:")
print(" source .env.release && swift Scripts/asc-submit.swift --submit")
exit(0)
}
let submitBody = "{\"data\":{\"type\":\"reviewSubmissions\",\"id\":\"\(submissionID!)\",\"attributes\":{\"submitted\":true}}}".data(using: .utf8)!
let (fStatus, fData) = request("PATCH", "\(api)/reviewSubmissions/\(submissionID!)", body: submitBody)
if fStatus == 200 {
let state = dataObject(fData).map { attrs($0)["state"] as? String ?? "WAITING_FOR_REVIEW" } ?? "WAITING_FOR_REVIEW"
print("")
print("🚀 Submitted for review — state: \(state)")
} else {
print("")
print("❌ Submit failed (status \(fStatus)). App Store Connect lists exactly what's")
print(" missing — screenshots, description, age rating, privacy, pricing, etc.:")
print(bodyText(fData))
exit(1)
}