#!/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")