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