From 6c01942406ab25e7c135e6a557379b14cf75d8a2 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 5 Jul 2026 09:15:11 -0400 Subject: [PATCH] Repoint App Store publishing at the appstore-publish skill's canonical scripts Remove the per-app asc-*.swift copies (now maintained only in indie-skills/skills/appstore-publish/scripts/) and add a follow-up hint to release.sh pointing at the canonical, in-place invocation with Workouts' bundle ID and marketing version. --- Scripts/asc-metadata.swift | 389 ------------------------------- Scripts/asc-set-compliance.swift | 112 --------- Scripts/asc-submit.swift | 280 ---------------------- Scripts/asc.swift | 59 ----- Scripts/release.sh | 9 + 5 files changed, 9 insertions(+), 840 deletions(-) delete mode 100755 Scripts/asc-metadata.swift delete mode 100755 Scripts/asc-set-compliance.swift delete mode 100755 Scripts/asc-submit.swift delete mode 100755 Scripts/asc.swift diff --git a/Scripts/asc-metadata.swift b/Scripts/asc-metadata.swift deleted file mode 100755 index 7a9eba9..0000000 --- a/Scripts/asc-metadata.swift +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env swift -// -// asc-metadata.swift — push App Store listing metadata from local files to -// App Store Connect via the API. Run before Scripts/asc-submit.swift. -// -// Usage: source .env.release && swift Scripts/asc-metadata.swift [options] -// -// Options: -// --version Marketing version to write (default: CFBundleShort- -// VersionString from Workouts/Resources/Info-iOS.plist). Created if absent. -// --bundle Bundle identifier (default: dev.rzen.indie.Workouts). -// --dir Metadata folder (default: Scripts/metadata). -// --dry-run Print what would change; make no writes. -// -// Folder layout (everything is optional — only what's present is pushed): -// -// metadata/ -// app/ -// categories.json {"primary":"HEALTH_AND_FITNESS","secondary":null} -// / e.g. en-US -// name.txt app name (≤30) -> appInfoLocalizations -// subtitle.txt subtitle (≤30) -// privacy_url.txt privacy policy URL -// version/ -// review.json {"contactFirstName":..,"contactEmail":..,"notes":..} -// / -// description.txt description (≤4000) -> appStoreVersionLocalizations -// keywords.txt comma-separated (≤100) -// promotional_text.txt promo text (≤170) -// whats_new.txt release notes (≤4000) (omit for first version) -// marketing_url.txt -// support_url.txt -// screenshots/ -// / -// / e.g. APP_IPHONE_67, APP_WATCH_ULTRA -// 01_first.png 02_second.png … (sorted by name; the local set is -// authoritative — existing remote -// shots in that set are replaced) -// -// What it pushes: app name / subtitle / privacy URL / categories, version -// description / keywords / promo / what's-new / URLs, screenshots, and the -// review contact details. NOT here (separate resource families): age rating, -// pricing/availability, and App Privacy labels. -// -// Reads ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment. -// -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 metaDir = "Scripts/metadata" -var dryRun = false -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 "--dir": metaDir = value("--dir") - case "--dry-run": dryRun = true - case "--help", "-h": - print("usage: swift Scripts/asc-metadata.swift [--version X] [--bundle id] [--dir path] [--dry-run]") - exit(0) - default: die("unknown argument: \(args[i])") - } - i += 1 - } -} - -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 (fresh per request) ------------------------------------------ -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]) ?? [:] } -func intval(_ any: Any?) -> Int { (any as? Int) ?? (any as? NSNumber)?.intValue ?? 0 } - -/// Build a JSON:API request body. Text goes through JSONSerialization, so -/// quotes / newlines in a description are escaped correctly. -func apiBody(type: String, id: String? = nil, attributes: [String: Any]? = nil, relationships: [String: Any]? = nil) -> Data { - var data: [String: Any] = ["type": type] - if let id { data["id"] = id } - if let attributes, !attributes.isEmpty { data["attributes"] = attributes } - if let relationships, !relationships.isEmpty { data["relationships"] = relationships } - return (try? JSONSerialization.data(withJSONObject: ["data": data])) ?? Data() -} -func toOne(_ type: String, _ id: String) -> [String: Any] { ["data": ["type": type, "id": id]] } - -// ---- local files ------------------------------------------------------------ -let fm = FileManager.default -func fileText(_ path: String) -> String? { - guard let data = fm.contents(atPath: path), let s = String(data: data, encoding: .utf8) else { return nil } - let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed -} -func subdirs(_ path: String) -> [String] { - ((try? fm.contentsOfDirectory(atPath: path)) ?? []) - .filter { var d: ObjCBool = false; return fm.fileExists(atPath: "\(path)/\($0)", isDirectory: &d) && d.boolValue } - .sorted() -} -func files(_ path: String) -> [String] { - ((try? fm.contentsOfDirectory(atPath: path)) ?? []) - .filter { !$0.hasPrefix(".") } - .filter { var d: ObjCBool = false; return fm.fileExists(atPath: "\(path)/\($0)", isDirectory: &d) && !d.boolValue } - .sorted() -} -func md5hex(_ data: Data) -> String { Insecure.MD5.hash(data: data).map { String(format: "%02x", $0) }.joined() } - -/// Collect non-nil attributes from (file -> attribute) pairs in a locale dir. -func attributesFrom(_ dir: String, _ map: [(file: String, attr: String)]) -> [String: Any] { - var out: [String: Any] = [:] - for m in map { if let v = fileText("\(dir)/\(m.file)") { out[m.attr] = v } } - return out -} - -func patchOrCreate(_ label: String, type: String, existingID: String?, - attributes: [String: Any], relationships: [String: Any]) { - if attributes.isEmpty { return } - if dryRun { - print(" ~ \(label): \(attributes.keys.sorted().joined(separator: ", ")) \(existingID == nil ? "(create)" : "(update)")") - return - } - let (status, data): (Int, Data) - if let id = existingID { - (status, data) = request("PATCH", "\(api)/\(type)/\(id)", body: apiBody(type: type, id: id, attributes: attributes)) - } else { - (status, data) = request("POST", "\(api)/\(type)", body: apiBody(type: type, attributes: attributes, relationships: relationships)) - } - guard status == 200 || status == 201 else { die("\(label): write failed (status \(status)): \(bodyText(data))") } - print(" ✅ \(label): \(attributes.keys.sorted().joined(separator: ", "))") -} - -// ---- 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)) • version \(versionString)\(dryRun ? " [DRY RUN]" : "")") - -// ---- 2. resolve the editable appInfo (app-level metadata lives here) -------- -let liveStates: Set = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE", "REPLACED_WITH_NEW_INFO", "NOT_APPLICABLE"] -let (aiStatus, aiData) = request("GET", "\(api)/apps/\(appID)/appInfos") -guard aiStatus == 200 else { die("could not list appInfos (status \(aiStatus)): \(bodyText(aiData))") } -let appInfos = dataArray(aiData) -func appInfoState(_ o: [String: Any]) -> String { (attrs(o)["state"] ?? attrs(o)["appStoreState"]) as? String ?? "" } -guard let appInfo = appInfos.first(where: { !liveStates.contains(appInfoState($0)) }) ?? appInfos.first, - let appInfoID = appInfo["id"] as? String else { - die("no editable appInfo found") -} - -// ---- 3. find or create the editable App Store version ----------------------- -let (vStatus, vData) = request("GET", "\(api)/apps/\(appID)/appStoreVersions?filter%5Bplatform%5D=IOS&filter%5BversionString%5D=\(versionString)&fields%5BappStoreVersions%5D=versionString&limit=1") -guard vStatus == 200 else { die("could not query versions (status \(vStatus)): \(bodyText(vData))") } -var versionID = dataArray(vData).first?["id"] as? String -if versionID == nil { - if dryRun { - print("App Store version \(versionString): would create") - versionID = "" - } else { - let (s, d) = request("POST", "\(api)/appStoreVersions", - body: apiBody(type: "appStoreVersions", - attributes: ["platform": "IOS", "versionString": versionString], - relationships: ["app": toOne("apps", appID)])) - 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))") - } -} -let vID = versionID! - -// ---- 4. categories (app-level) ---------------------------------------------- -if let cData = fm.contents(atPath: "\(metaDir)/app/categories.json"), - let cats = try? JSONSerialization.jsonObject(with: cData) as? [String: Any] { - var rel: [String: Any] = [:] - if let p = cats["primary"] as? String { rel["primaryCategory"] = toOne("appCategories", p) } - if let s = cats["secondary"] as? String { rel["secondaryCategory"] = toOne("appCategories", s) } - if !rel.isEmpty { - if dryRun { - print("Categories: \(rel.keys.sorted().joined(separator: ", ")) (update)") - } else { - let (s, d) = request("PATCH", "\(api)/appInfos/\(appInfoID)", body: apiBody(type: "appInfos", id: appInfoID, relationships: rel)) - guard s == 200 else { die("category update failed (status \(s)): \(bodyText(d))") } - print("✅ categories set") - } - } -} - -// ---- 5. app-level localizations (name, subtitle, privacy URL) --------------- -let appLocRoot = "\(metaDir)/app" -let (ailStatus, ailData) = request("GET", "\(api)/appInfos/\(appInfoID)/appInfoLocalizations?limit=200") -let existingAppLocs = ailStatus == 200 ? dataArray(ailData) : [] -for locale in subdirs(appLocRoot) where locale != "categories.json" { - let dir = "\(appLocRoot)/\(locale)" - let attributes = attributesFrom(dir, [("name.txt", "name"), ("subtitle.txt", "subtitle"), ("privacy_url.txt", "privacyPolicyUrl")]) - guard !attributes.isEmpty else { continue } - print("App info • \(locale)") - let existing = existingAppLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String - var createAttrs = attributes - if existing == nil { createAttrs["locale"] = locale } - patchOrCreate("appInfoLocalizations[\(locale)]", type: "appInfoLocalizations", existingID: existing, - attributes: createAttrs, relationships: ["appInfo": toOne("appInfos", appInfoID)]) -} - -// ---- 6. version localizations (description, keywords, urls, promo, whats-new) -let verLocRoot = "\(metaDir)/version" -let (avlStatus, avlData) = request("GET", "\(api)/appStoreVersions/\(vID)/appStoreVersionLocalizations?limit=200") -let existingVerLocs = avlStatus == 200 ? dataArray(avlData) : [] -for locale in subdirs(verLocRoot) { - let dir = "\(verLocRoot)/\(locale)" - let attributes = attributesFrom(dir, [ - ("description.txt", "description"), - ("keywords.txt", "keywords"), - ("promotional_text.txt", "promotionalText"), - ("whats_new.txt", "whatsNew"), - ("marketing_url.txt", "marketingUrl"), - ("support_url.txt", "supportUrl"), - ]) - guard !attributes.isEmpty else { continue } - print("Version info • \(locale)") - let existing = existingVerLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String - var createAttrs = attributes - if existing == nil { createAttrs["locale"] = locale } - patchOrCreate("appStoreVersionLocalizations[\(locale)]", type: "appStoreVersionLocalizations", existingID: existing, - attributes: createAttrs, relationships: ["appStoreVersion": toOne("appStoreVersions", vID)]) -} - -// ---- 7. review details ------------------------------------------------------ -if let rData = fm.contents(atPath: "\(verLocRoot)/review.json"), - let review = try? JSONSerialization.jsonObject(with: rData) as? [String: Any], !review.isEmpty { - print("Review details") - let (rdStatus, rdData) = request("GET", "\(api)/appStoreVersions/\(vID)/appStoreReviewDetail") - let existing = rdStatus == 200 ? dataObject(rdData)?["id"] as? String : nil - patchOrCreate("appStoreReviewDetails", type: "appStoreReviewDetails", existingID: existing, - attributes: review, relationships: ["appStoreVersion": toOne("appStoreVersions", vID)]) -} - -// ---- 8. screenshots --------------------------------------------------------- -// The local folder is authoritative: for every display type present locally, -// the matching remote set is cleared and re-uploaded in filename order. -func uploadScreenshot(setID: String, fileURL: String) { - guard let data = fm.contents(atPath: fileURL) else { die("cannot read \(fileURL)") } - let name = (fileURL as NSString).lastPathComponent - // reserve - let (rs, rd) = request("POST", "\(api)/appScreenshots", - body: apiBody(type: "appScreenshots", - attributes: ["fileSize": data.count, "fileName": name], - relationships: ["appScreenshotSet": toOne("appScreenshotSets", setID)])) - guard rs == 201 || rs == 200, let obj = dataObject(rd), let shotID = obj["id"] as? String, - let ops = attrs(obj)["uploadOperations"] as? [[String: Any]] else { - die("reserve failed for \(name) (status \(rs)): \(bodyText(rd))") - } - // upload each chunk to Apple's pre-signed URLs (no bearer token — use the - // headers Apple hands back) - for op in ops { - guard let urlStr = op["url"] as? String, let url = URL(string: urlStr) else { die("bad upload op for \(name)") } - let offset = intval(op["offset"]); let length = intval(op["length"]) - let chunk = data.subdata(in: offset ..< (offset + length)) - var req = URLRequest(url: url) - req.httpMethod = (op["method"] as? String) ?? "PUT" - for h in (op["requestHeaders"] as? [[String: Any]]) ?? [] { - if let n = h["name"] as? String, let v = h["value"] as? String { req.setValue(v, forHTTPHeaderField: n) } - } - req.httpBody = chunk - let sem = DispatchSemaphore(value: 0); var st = 0 - URLSession.shared.dataTask(with: req) { _, resp, err in - if let err { die("upload error for \(name): \(err)") } - st = (resp as? HTTPURLResponse)?.statusCode ?? 0; sem.signal() - }.resume() - sem.wait() - guard (200...299).contains(st) else { die("chunk upload failed for \(name) (status \(st))") } - } - // commit - let (cs, cd) = request("PATCH", "\(api)/appScreenshots/\(shotID)", - body: apiBody(type: "appScreenshots", id: shotID, - attributes: ["uploaded": true, "sourceFileChecksum": md5hex(data)])) - guard cs == 200 else { die("commit failed for \(name) (status \(cs)): \(bodyText(cd))") } - print(" ⬆︎ \(name)") -} - -let shotRoot = "\(metaDir)/screenshots" -for locale in subdirs(shotRoot) { - // existing screenshot sets for this locale's version localization - let locID = existingVerLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String - for displayType in subdirs("\(shotRoot)/\(locale)") { - let imgDir = "\(shotRoot)/\(locale)/\(displayType)" - let images = files(imgDir) - guard !images.isEmpty else { continue } - print("Screenshots • \(locale) • \(displayType) (\(images.count))") - if dryRun { images.forEach { print(" ⬆︎ \($0) (dry-run)") }; continue } - guard let locID else { die("no version localization for \(locale); add version/\(locale)/ text first") } - // find or create the set - let (ssStatus, ssData) = request("GET", "\(api)/appStoreVersionLocalizations/\(locID)/appScreenshotSets?limit=200") - var setID = (ssStatus == 200 ? dataArray(ssData) : []).first { attrs($0)["screenshotDisplayType"] as? String == displayType }?["id"] as? String - if setID == nil { - let (s, d) = request("POST", "\(api)/appScreenshotSets", - body: apiBody(type: "appScreenshotSets", - attributes: ["screenshotDisplayType": displayType], - relationships: ["appStoreVersionLocalization": toOne("appStoreVersionLocalizations", locID)])) - guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else { - die("could not create screenshot set \(displayType) (status \(s)): \(bodyText(d))") - } - setID = id - } else { - // clear existing shots so the local set is authoritative - let (es, ed) = request("GET", "\(api)/appScreenshotSets/\(setID!)/appScreenshots?limit=200") - for shot in (es == 200 ? dataArray(ed) : []) { - if let id = shot["id"] as? String { request("DELETE", "\(api)/appScreenshots/\(id)") } - } - } - for img in images { uploadScreenshot(setID: setID!, fileURL: "\(imgDir)/\(img)") } - } -} - -print("") -print(dryRun ? "✅ Dry run complete — no changes written." : "✅ Metadata pushed. Next: swift Scripts/asc-submit.swift") diff --git a/Scripts/asc-set-compliance.swift b/Scripts/asc-set-compliance.swift deleted file mode 100755 index 6dc8917..0000000 --- a/Scripts/asc-set-compliance.swift +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env swift -// -// asc-set-compliance.swift — declare export compliance for builds that are -// missing it (usesNonExemptEncryption = NO) via the App Store Connect API. -// -// Usage: source .env.release && swift Scripts/asc-set-compliance.swift [bundleId] -// -// Only needed for builds uploaded WITHOUT ITSAppUsesNonExemptEncryption in -// Info.plist; builds that carry that key declare compliance automatically. -// Reads ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment. -// -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: "") -} - -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)") -} -let bundleID = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "dev.rzen.indie.Workouts" - -// ---- short-lived ES256 JWT -------------------------------------------------- -guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8) else { die("cannot read key at \(keyPath)") } -guard let key = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("could not parse EC key at \(keyPath)") } -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? key.signature(for: Data(signingInput.utf8)) else { die("signing failed") } -let jwt = signingInput + "." + b64url(sig.rawRepresentation) - -// ---- API plumbing ----------------------------------------------------------- -let api = "https://api.appstoreconnect.apple.com/v1" - -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 \(jwt)", 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) ?? "" } - -// ---- find the app ----------------------------------------------------------- -let (aStatus, aData) = request("GET", "\(api)/apps?filter%5BbundleId%5D=\(bundleID)&fields%5Bapps%5D=bundleId,name") -guard aStatus == 200, - let aArr = parse(aData)?["data"] as? [[String: Any]], - let app = aArr.first, - let appID = app["id"] as? String else { - die("app not found for bundle \(bundleID) (status \(aStatus)): \(bodyText(aData))") -} -print("App: \(bundleID) (\(appID))") - -// ---- list builds and declare any missing compliance ------------------------- -let (bStatus, bData) = request("GET", "\(api)/builds?filter%5Bapp%5D=\(appID)&fields%5Bbuilds%5D=version,usesNonExemptEncryption,processingState,uploadedDate&limit=200") -guard bStatus == 200, let builds = parse(bData)?["data"] as? [[String: Any]] else { - die("could not list builds (status \(bStatus)): \(bodyText(bData))") -} -if builds.isEmpty { print("No builds found yet (still processing?)."); exit(0) } - -var patched = 0, already = 0, failed = 0 -for build in builds { - guard let id = build["id"] as? String, let attrs = build["attributes"] as? [String: Any] else { continue } - let version = attrs["version"] as? String ?? "?" - let state = attrs["processingState"] as? String ?? "?" - let needs = (attrs["usesNonExemptEncryption"] ?? NSNull()) is NSNull - let uploaded = attrs["uploadedDate"] as? String ?? "?" - let encVal = attrs["usesNonExemptEncryption"] ?? NSNull() - if !needs { - print("• build \(version) [\(state)] uploaded \(uploaded): already declared (usesNonExemptEncryption=\(encVal))") - already += 1 - continue - } - let body = "{\"data\":{\"type\":\"builds\",\"id\":\"\(id)\",\"attributes\":{\"usesNonExemptEncryption\":false}}}".data(using: .utf8)! - let (pStatus, pData) = request("PATCH", "\(api)/builds/\(id)", body: body) - if pStatus == 200 { - print("✅ build \(version) [\(state)]: export compliance set (uses non-exempt encryption = NO)") - patched += 1 - } else { - print("❌ build \(version) [\(state)]: PATCH failed (status \(pStatus)): \(bodyText(pData))") - failed += 1 - } -} -print("Done. \(patched) updated, \(already) already set, \(failed) failed.") -if failed > 0 { exit(1) } diff --git a/Scripts/asc-submit.swift b/Scripts/asc-submit.swift deleted file mode 100755 index e48eab1..0000000 --- a/Scripts/asc-submit.swift +++ /dev/null @@ -1,280 +0,0 @@ -#!/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 Marketing version to submit (default: CFBundleShort- -// VersionString from Workouts/Resources/Info-iOS.plist). -// --build Build number (CFBundleVersion) to attach (default: the -// most recent build uploaded for the app). -// --bundle 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) -} diff --git a/Scripts/asc.swift b/Scripts/asc.swift deleted file mode 100755 index fd62223..0000000 --- a/Scripts/asc.swift +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env swift -// Generic ASC API helper: asc.swift [json-body] -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: "") -} - -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 env") -} -guard CommandLine.arguments.count >= 3 else { die("usage: asc.swift [body]") } -let method = CommandLine.arguments[1].uppercased() -let path = CommandLine.arguments[2] -let body = CommandLine.arguments.count > 3 ? CommandLine.arguments[3].data(using: .utf8) : nil - -guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8), - let key = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("key error") } -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? key.signature(for: Data(signingInput.utf8)) else { die("signing failed") } -let jwt = signingInput + "." + b64url(sig.rawRepresentation) - -let base = path.hasPrefix("v2/") ? "https://api.appstoreconnect.apple.com/" : "https://api.appstoreconnect.apple.com/v1/" -var req = URLRequest(url: URL(string: base + path)!) -req.httpMethod = method -req.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") -if let body { - req.httpBody = body - req.setValue("application/json", forHTTPHeaderField: "Content-Type") -} -let sem = DispatchSemaphore(value: 0) -URLSession.shared.dataTask(with: req) { d, resp, err in - if let err { die("network: \(err)") } - let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 - print("HTTP \(status)") - if let d, !d.isEmpty { - if let obj = try? JSONSerialization.jsonObject(with: d), - let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) { - print(String(data: pretty, encoding: .utf8)!) - } else { - print(String(data: d, encoding: .utf8) ?? "") - } - } - sem.signal() -}.resume() -sem.wait() diff --git a/Scripts/release.sh b/Scripts/release.sh index 0a6e9d9..f5e522a 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -77,3 +77,12 @@ xcodebuild -exportArchive \ echo "" echo "✅ Uploaded build $BUILD_NUMBER. Appears in App Store Connect > TestFlight after processing (~5–15 min)." +echo "" +echo "➡️ Push metadata, then stage/submit for review with the canonical scripts" +echo " (apps sit as siblings of indie-skills under ~/Documents/indie; --bundle" +echo " and --version are required flags — there are no per-app defaults):" +echo "" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.Workouts --version $MARKETING_VERSION --dry-run" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.Workouts --version $MARKETING_VERSION" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.Workouts --version $MARKETING_VERSION # stage only" +echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.Workouts --version $MARKETING_VERSION --submit # stage + submit"