85d0eaddbb
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.
281 lines
15 KiB
Swift
Executable File
281 lines
15 KiB
Swift
Executable File
#!/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)
|
||
}
|