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.
This commit is contained in:
@@ -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 <X> Marketing version to write (default: CFBundleShort-
|
|
||||||
// VersionString from Workouts/Resources/Info-iOS.plist). Created if absent.
|
|
||||||
// --bundle <id> Bundle identifier (default: dev.rzen.indie.Workouts).
|
|
||||||
// --dir <path> 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}
|
|
||||||
// <locale>/ 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":..}
|
|
||||||
// <locale>/
|
|
||||||
// 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/
|
|
||||||
// <locale>/
|
|
||||||
// <DISPLAY_TYPE>/ 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<String> = ["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 = "<new>"
|
|
||||||
} 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")
|
|
||||||
@@ -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) }
|
|
||||||
@@ -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 <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)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env swift
|
|
||||||
// Generic ASC API helper: asc.swift <get|post|patch|delete> <path-after-/v1/> [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 <method> <path> [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()
|
|
||||||
@@ -77,3 +77,12 @@ xcodebuild -exportArchive \
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Uploaded build $BUILD_NUMBER. Appears in App Store Connect > TestFlight after processing (~5–15 min)."
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user