Skip to content

Commit

Permalink
Add script to automate sending build to TestFlight
Browse files Browse the repository at this point in the history
  • Loading branch information
saagarjha committed Feb 24, 2024
1 parent f618870 commit be8bb0d
Show file tree
Hide file tree
Showing 3 changed files with 383 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,10 @@ jobs:

- name: Upload visionOS
run: xcodebuild $XCODEBUILD_EXTRA_ARGS -exportArchive -exportOptionsPlist Release/ExportOptions.plist -archivePath visionOS.xcarchive/ -exportPath visionOS

- name: Send to TestFlight
env:
AUTHENTICATION_KEY=/tmp/AuthKey.p8
AUTHENTICATION_KEY_ID=${{ secrets.AUTHENTICATION_KEY_ID }}
AUTHENTICATION_KEY_ISSUER_ID=${{ secrets.AUTHENTICATION_KEY_ISSUER_ID }}
run: Release/send_to_testflight.sh
4 changes: 4 additions & 0 deletions Release/generate_notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh

LAST_TAG="$(git tag --sort=-version:refname | head -2 | tail -n 1)"
git log "$LAST_TAG"..HEAD --pretty=format:"[%as] %h: %s (%aN <%aE>)"
372 changes: 372 additions & 0 deletions Release/send_to_testflight.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,372 @@
#!/usr/bin/env DYLD_FRAMEWORK_PATH=/System/Library/Frameworks swift
// ^ Temporary workaround for https://github.com/apple/swift/issues/68785

import CryptoKit
import Foundation

struct API {
struct _API {
let header: String
let issuerID: String
let privateKey: P256.Signing.PrivateKey

init(key: Data, keyID: String, issuerID: String) throws {
header = try JSONEncoder().encode([
"alg": "ES256",
"kid": keyID,
"typ": "JWT",
]).base64EncodedString().filter {
$0 != "="
}

self.issuerID = issuerID

let pem = String(data: key, encoding: .utf8)!
privateKey = try P256.Signing.PrivateKey(pemRepresentation: pem)
}

func generateJWT() throws -> String {
let payload = try JSONSerialization.data(
withJSONObject: [
"iss": issuerID,
"iat": Date.now.timeIntervalSince1970,
"exp": Date.now.addingTimeInterval(2 * 60).timeIntervalSince1970,
"aud": "appstoreconnect-v1",
] as [String: Any]
).base64EncodedString().filter {
$0 != "="
}

let signature = try privateKey.signature(for: Data((header + "." + payload).utf8)).rawRepresentation.base64EncodedString().filter {
$0 != "="
}

return header + "." + payload + "." + signature
}

func _getRequest(endpoint: String) async throws -> Data {
var request = URLRequest(url: URL(string: endpoint)!)
request.addValue("Bearer \(try generateJWT())", forHTTPHeaderField: "Authorization")
return try await URLSession.shared.data(for: request).0
}

func getRequest<T: Codable>(endpoint: String, parsing response: T.Type) async throws -> T {
var request = URLRequest(url: URL(string: endpoint)!)
request.addValue("Bearer \(try generateJWT())", forHTTPHeaderField: "Authorization")
return try JSONDecoder().decode(T.self, from: await URLSession.shared.data(for: request).0)
}

func _postyRequest(endpoint: String, method: String = "POST", object: Encodable) async throws -> Data {
var request = URLRequest(url: URL(string: endpoint)!)
request.addValue("Bearer \(try generateJWT())", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = method
request.httpBody = try JSONEncoder().encode(object)
return try await URLSession.shared.data(for: request).0
}

struct Response<T: Codable>: Codable {
struct Links: Codable {
let next: String?
}

let data: [T]
let links: Links
}

func pagedGetRequest<T: Codable>(endpoint: String, parsing data: T.Type) async throws -> [T] {
var result = [T]()
var nextEndpoint = Optional.some(endpoint)
while let endpoint = nextEndpoint {
let next = try await getRequest(endpoint: endpoint, parsing: Response<T>.self)
result.append(contentsOf: next.data)
nextEndpoint = next.links.next
}
return result
}
}

let _api: _API

init(key: Data, keyID: String, issuerID: String) throws {
_api = try .init(key: key, keyID: keyID, issuerID: issuerID)
}

struct App: Codable {
struct Attributes: Codable {
let bundleId: String
}

let id: String
let attributes: Attributes
}

func apps() async throws -> [App] {
try await _api.pagedGetRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/apps", parsing: App.self)
}

struct AppBuild: Codable {
struct Attributes: Codable {
let version: String
}

let id: String
let attributes: Attributes
}

func builds(forAppID appID: String) async throws -> [AppBuild] {
try await _api.pagedGetRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/apps/\(appID)/builds", parsing: AppBuild.self)
}

struct PrereleaseVersion: Codable {
struct Attributes: Codable {
enum Platform: String, Codable {
case IOS
case MAC_OS
case TV_OS
case VISION_OS
}

let platform: Platform
}

let id: String
let attributes: Attributes
}

func prereleaseVersion(forBuildID buildID: String) async throws -> PrereleaseVersion {
struct Response: Codable {
let data: PrereleaseVersion
}

return try await _api.getRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/builds/\(buildID)/preReleaseVersion?fields[preReleaseVersions]=platform", parsing: Response.self).data
}

struct Build: Codable {
struct Attributes: Codable {
enum ProcessingState: String, Codable {
case PROCESSING
case FAILED
case INVALID
case VALID
}

let processingState: ProcessingState
}

let id: String
let attributes: Attributes
}

func build(forBuildID buildID: String) async throws -> Build {
struct Response: Codable {
let data: Build
}

return try await _api.getRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/builds/\(buildID)", parsing: Response.self).data
}

struct BetaLocalization: Codable {
struct Attributes: Codable {
let whatsNew: String?
let locale: String
}

let id: String
let attributes: Attributes
}

func betaLocalizations(forBuildID buildID: String) async throws -> [BetaLocalization] {
try await _api.pagedGetRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/builds/\(buildID)/betaBuildLocalizations", parsing: BetaLocalization.self)
}

func updateWhatsNew(_ whatsNew: String, forBetaLocalizationID betaLocalizationID: String) async throws {
struct Request: Encodable {
struct BetaLocalizationUpdate: Encodable {
struct Attributes: Encodable {
let whatsNew: String
}

let id: String
let type = "betaBuildLocalizations"
let attributes: Attributes
}

let data: BetaLocalizationUpdate
}
let request = Request(data: .init(id: betaLocalizationID, attributes: .init(whatsNew: whatsNew)))
_ = try await _api._postyRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/betaBuildLocalizations/\(betaLocalizationID)", method: "PATCH", object: request)
}

struct BetaGroup: Codable {
struct Attributes: Codable {
let name: String
let isInternalGroup: Bool
}

let id: String
let attributes: Attributes
}

func betaGroups() async throws -> [BetaGroup] {
try await _api.pagedGetRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/betaGroups", parsing: BetaGroup.self)
}

struct BetaGroupBuild: Codable {
let id: String
}

func builds(forBetaGroupID betaGroupID: String) async throws -> [BetaGroupBuild] {
try await _api.pagedGetRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/betaGroups/\(betaGroupID)/builds", parsing: BetaGroupBuild.self)
}

func setBuilds(buildIDs: [String], toBetaGroupID betaGroupID: String) async throws {
struct Request: Encodable {
struct BetaGroupBuild: Encodable {
let id: String
let type = "builds"
}

let data: [BetaGroupBuild]
}
let request = Request(data: buildIDs.map(Request.BetaGroupBuild.init(id:)))
_ = try await _api._postyRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/betaGroups/\(betaGroupID)/relationships/builds", object: request)
}

func submitBuildForReview(buildID: String) async throws {
struct Request: Encodable {
struct Submission: Encodable {
struct Relationships: Encodable {
struct Build: Encodable {
struct Data: Encodable {
let id: String
let type = "builds"
}

let data: Data
}

let build: Build
}

let type = "betaAppReviewSubmissions"
let relationships: Relationships
}

let data: Submission
}
let request = Request(data: .init(relationships: .init(build: .init(data: .init(id: buildID)))))
_ = try await _api._postyRequest(endpoint: "https://api.appstoreconnect.apple.com/v1/betaAppReviewSubmissions", object: request)
}
}

let build = CommandLine.arguments[1]
print("Performing steps for build \(build)...")

let _key = ProcessInfo.processInfo.environment["AUTHENTICATION_KEY"]!
let keyID = ProcessInfo.processInfo.environment["AUTHENTICATION_KEY_ID"]!
let issuerID = ProcessInfo.processInfo.environment["AUTHENTICATION_KEY_ISSUER_ID"]!
print("Loading authentication from \(_key), keyID \(keyID), \(issuerID)...", terminator: "")

let key = try Data(contentsOf: URL(fileURLWithPath: _key))
let api = try API(key: key, keyID: keyID, issuerID: issuerID)
print("Loaded")

print("Listing apps...", terminator: "")
let apps = try await api.apps()
let appID = apps.first {
$0.attributes.bundleId == "com.saagarjha.MacCast"
}!.id
print("Found app ID \(appID)")

// Even though we should've uploaded builds before running this script, they
// might not be listed yet.
print("Waiting for builds to become available...")
var builds: [API.AppBuild]
repeat {
print("Listing builds...", terminator: "")
builds = try await api.builds(forAppID: appID).filter {
$0.attributes.version == build
}
print("Found \(builds.count) builds")
guard builds.count != 2 else {
break
}
try await Task.sleep(for: .seconds(10))
} while true

var buildPlatforms = [API.PrereleaseVersion]()
for build in builds {
print("Looking up platform for build \(build.id)...", terminator: "")
let version = try await api.prereleaseVersion(forBuildID: build.id)
buildPlatforms.append(version)
print("\(version.attributes.platform.rawValue)")
}

let macOSBuild = builds[buildPlatforms.firstIndex {
$0.attributes.platform == .MAC_OS
}!]
let visionOSBuild = builds[buildPlatforms.firstIndex {
$0.attributes.platform == .VISION_OS
}!]

print("Waiting for builds to process...")

func waitForBuildToProcess(buildID: String) async throws -> API.Build.Attributes.ProcessingState {
while true {
let build = try await api.build(forBuildID: buildID)
print("Build \(buildID) is \(build.attributes.processingState.rawValue)!")
guard build.attributes.processingState == .PROCESSING else {
return build.attributes.processingState
}
try await Task.sleep(for: .seconds(30))
}
}

let (macOSStatus, visionOSStatus) = (try await waitForBuildToProcess(buildID: macOSBuild.id), try await waitForBuildToProcess(buildID: visionOSBuild.id))
precondition(macOSStatus == .VALID)
precondition(visionOSStatus == .VALID)

print("Generating notes...", terminator: "")
let output = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "Release/generate_notes.sh")
process.standardOutput = output
try process.run()
process.waitUntilExit()
let notes = String(data: try output.fileHandleForReading.readToEnd()!, encoding: .utf8)!
print("Generated")
print("Notes:")
for line in notes.split(separator: "\n") {
print("\t\(line)")
}

print("Listing beta groups...", terminator: "")
let betaGroup = try await api.betaGroups().first {
$0.attributes.name == "Test" && !$0.attributes.isInternalGroup
}!
print("Found beta group \(betaGroup.id)")

print("Finding old beta builds in group...", terminator: "")
let betaBuilds = try await api.builds(forBetaGroupID: betaGroup.id)
print("Found \(betaBuilds.count) builds")

print("Adding new builds to group...", terminator: "")
try await api.setBuilds(buildIDs: betaBuilds.map(\.id) + [macOSBuild.id, visionOSBuild.id], toBetaGroupID: betaGroup.id)
print("Added")

for build in [macOSBuild, visionOSBuild] {
print("Finding localization ID for build \(build.id)...", terminator: "")
let localization = try await api.betaLocalizations(forBuildID: build.id).first {
$0.attributes.locale == "en-US"
}!
print("Found")

print("Updating notes for \(localization.id)...", terminator: "")
try await api.updateWhatsNew(notes, forBetaLocalizationID: localization.id)
print("Updated")

print("Submitting build \(build.id) for review...", terminator: "")
try await api.submitBuildForReview(buildID: build.id)
print("")
}

0 comments on commit be8bb0d

Please sign in to comment.