diff --git a/.gitignore b/.gitignore index 38ea572..008ea46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ .DS_Store -/.build +.build /Packages /*.xcodeproj xcuserdata/ DerivedData/ -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm +Package.resolved MatrixSDKFFI.xcframework.zip diff --git a/Tools/Release/Package.swift b/Tools/Release/Package.swift new file mode 100644 index 0000000..cf0fb69 --- /dev/null +++ b/Tools/Release/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Release", + platforms: [.macOS(.v13)], + products: [.executable(name: "release", targets: ["Release"])], + dependencies: [.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")], + targets: [ + .executableTarget(name: "Release", dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]) + ] +) diff --git a/Tools/Scripts/README.md b/Tools/Release/README.md similarity index 89% rename from Tools/Scripts/README.md rename to Tools/Release/README.md index 8bf49f9..22541ec 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Release/README.md @@ -5,10 +5,10 @@ Creates a Github release from a matrix-rust-sdk repository. Usage: ``` -python3 release.py --version v1.0.18-alpha +swift run release --version v1.0.18-alpha ``` -For help: `release.py -h` +For help: `swift run release --help` ## Requirements diff --git a/Tools/Release/Sources/Netrc.swift b/Tools/Release/Sources/Netrc.swift new file mode 100644 index 0000000..77de2fa --- /dev/null +++ b/Tools/Release/Sources/Netrc.swift @@ -0,0 +1,179 @@ +// https://github.com/apple/swift-package-manager/blob/main/Sources/Basics/Netrc.swift +// +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// Representation of Netrc configuration +public struct Netrc { + /// Representation of `machine` connection settings & `default` connection settings. + /// If `default` connection settings present, they will be last element. + public let machines: [Machine] + + fileprivate init(machines: [Machine]) { + self.machines = machines + } + + /// Returns auth information + /// + /// - Parameters: + /// - url: The url to retrieve authorization information for. + public func authorization(for url: URL) -> Authorization? { + guard let index = machines.firstIndex(where: { $0.name == url.host }) ?? machines + .firstIndex(where: { $0.isDefault }) + else { + return .none + } + let machine = self.machines[index] + return Authorization(login: machine.login, password: machine.password) + } + + /// Representation of connection settings + public struct Machine: Equatable { + public let name: String + public let login: String + public let password: String + + public var isDefault: Bool { + self.name == "default" + } + + public init(name: String, login: String, password: String) { + self.name = name + self.login = login + self.password = password + } + + init?(for match: NSTextCheckingResult, string: String, variant: String = "") { + guard let name = RegexUtil.Token.machine.capture(in: match, string: string) ?? RegexUtil.Token.default + .capture(in: match, string: string), + let login = RegexUtil.Token.login.capture(prefix: variant, in: match, string: string), + let password = RegexUtil.Token.password.capture(prefix: variant, in: match, string: string) + else { + return nil + } + self = Machine(name: name, login: login, password: password) + } + } + + /// Representation of authorization information + public struct Authorization: Equatable { + public let login: String + public let password: String + + public init(login: String, password: String) { + self.login = login + self.password = password + } + } +} + +public struct NetrcParser { + /// Parses a netrc file at the give location + /// + /// - Parameters: + /// - fileSystem: The file system to use. + /// - path: The file to parse + public static func parse(file: URL) throws -> Netrc { + guard FileManager.default.fileExists(atPath: file.path()) else { + throw NetrcError.fileNotFound(file) + } + guard FileManager.default.isReadableFile(atPath: file.path()) else { + throw NetrcError.unreadableFile(file) + } + let content = try String(contentsOf: file) + return try Self.parse(content) + } + + /// Parses stringified netrc content + /// + /// - Parameters: + /// - content: The content to parse + public static func parse(_ content: String) throws -> Netrc { + let content = self.trimComments(from: content) + let regex = try! NSRegularExpression(pattern: RegexUtil.netrcPattern, options: []) + let matches = regex.matches( + in: content, + options: [], + range: NSRange(content.startIndex ..< content.endIndex, in: content) + ) + + let machines: [Netrc.Machine] = matches.compactMap { + Netrc.Machine(for: $0, string: content, variant: "lp") ?? Netrc + .Machine(for: $0, string: content, variant: "pl") + } + + if let defIndex = machines.firstIndex(where: { $0.isDefault }) { + guard defIndex == machines.index(before: machines.endIndex) else { + throw NetrcError.invalidDefaultMachinePosition + } + } + guard machines.count > 0 else { + throw NetrcError.machineNotFound + } + return Netrc(machines: machines) + } + + /// Utility method to trim comments from netrc content + /// - Parameter text: String text of netrc file + /// - Returns: String text of netrc file *sans* comments + private static func trimComments(from text: String) -> String { + let regex = try! NSRegularExpression(pattern: RegexUtil.comments, options: .anchorsMatchLines) + let nsString = text as NSString + let range = NSRange(location: 0, length: nsString.length) + let matches = regex.matches(in: text, range: range) + var trimmedCommentsText = text + matches.forEach { + trimmedCommentsText = trimmedCommentsText + .replacingOccurrences(of: nsString.substring(with: $0.range), with: "") + } + return trimmedCommentsText + } +} + +public enum NetrcError: Error, Equatable { + case fileNotFound(URL) + case unreadableFile(URL) + case machineNotFound + case invalidDefaultMachinePosition +} + +private enum RegexUtil { + @frozen + fileprivate enum Token: String, CaseIterable { + case machine, login, password, account, macdef, `default` + + func capture(prefix: String = "", in match: NSTextCheckingResult, string: String) -> String? { + guard let range = Range(match.range(withName: prefix + rawValue), in: string) else { return nil } + return String(string[range]) + } + } + + static let comments: String = "\\#[\\s\\S]*?.*$" + static let `default`: String = #"(?:\s*(?default))"# + static let accountOptional: String = #"(?:\s*account\s+\S++)?"# + static let loginPassword: String = + #"\#(namedTrailingCapture("login", prefix: "lp"))\#(accountOptional)\#(namedTrailingCapture("password", prefix: "lp"))"# + static let passwordLogin: String = + #"\#(namedTrailingCapture("password", prefix: "pl"))\#(accountOptional)\#(namedTrailingCapture("login", prefix: "pl"))"# + static let netrcPattern = + #"(?:(?:(\#(namedTrailingCapture("machine"))|\#(namedMatch("default"))))(?:\#(loginPassword)|\#(passwordLogin)))"# + + static func namedMatch(_ string: String) -> String { + #"(?:\s*(?<\#(string)>\#(string)))"# + } + + static func namedTrailingCapture(_ string: String, prefix: String = "") -> String { + #"\s*\#(string)\s+(?<\#(prefix + string)>\S++)"# + } +} diff --git a/Tools/Release/Sources/Release.swift b/Tools/Release/Sources/Release.swift new file mode 100644 index 0000000..1b6adef --- /dev/null +++ b/Tools/Release/Sources/Release.swift @@ -0,0 +1,233 @@ +import ArgumentParser +import CryptoKit +import Foundation + +@main +struct Release: AsyncParsableCommand { + + @Option(help: "The version of the library that is being released.") + var version: String + + var apiToken = (try? NetrcParser.parse(file: FileManager.default.homeDirectoryForCurrentUser.appending(component: ".netrc")))! + .authorization(for: URL(string: "https://api.github.com")!)! + .password + + var packageRepo = "matrix-org/matrix-rust-components-swift" + var packageDirectory = URL(fileURLWithPath: #file) + .deletingLastPathComponent() // Release.swift + .deletingLastPathComponent() // Sources + .deletingLastPathComponent() // Release + .deletingLastPathComponent() // Tools + + lazy var buildDirectory = packageDirectory + .deletingLastPathComponent() // matrix-rust-components-swift + .appending(component: "matrix-rust-sdk") + + mutating func run() async throws { + info("Build directory: \(buildDirectory.path())") + + let libraryDirectory = try buildLibrary() + let (zipFileURL, checksum) = try zipBinary(at: libraryDirectory) + + try await updatePackage(from: libraryDirectory, checksum: checksum) + let commitHash = try commitAndPush() + try await makeRelease(at: commitHash, uploading: zipFileURL) + } + + mutating func buildLibrary() throws -> URL { + // unset fixes an issue where swift compilation prevents building for targets other than macOS X + try run(command: "unset SDKROOT && cargo xtask swift build-framework --release", directory: buildDirectory) + return buildDirectory.appending(component: "bindings/apple/generated/") + } + + mutating func zipBinary(at libraryDirectory: URL) throws -> (URL, String) { + let zipFileURL = buildDirectory.appending(component: "MatrixSDKFFI.xcframework.zip") + if FileManager.default.fileExists(atPath: zipFileURL.path()) { + info("Deleting old framework") + try FileManager.default.removeItem(at: zipFileURL) + } + + info("Zipping framework") + try run(command: "zip -r '\(zipFileURL.path())' MatrixSDKFFI.xcframework", directory: libraryDirectory) + let checksum = try checksum(for: zipFileURL) + info("Checksum: \(checksum)") + + return (zipFileURL, checksum) + } + + func updatePackage(from libraryDirectory: URL, checksum: String) async throws { + info("Copying sources") + let source = libraryDirectory.appending(component: "swift", directoryHint: .isDirectory) + let destination = packageDirectory.appending(component: "Sources/MatrixRustSDK", directoryHint: .isDirectory) + try run(command: "rsync -a --delete '\(source.path())' '\(destination.path())'") + + info("Updating manifest") + let manifestURL = packageDirectory.appending(component: "Package.swift") + var updatedManifest = "" + + #warning("Strips empty lines") + for try await line in manifestURL.lines { + if line.starts(with: "let version = ") { + updatedManifest.append("let version = \"\(version)\"") + } else if line.starts(with: "let checksum = ") { + updatedManifest.append("let checksum = \"\(checksum)\"") + } else { + updatedManifest.append(line) + } + updatedManifest.append("\n") + } + + try updatedManifest.write(to: manifestURL, atomically: true, encoding: .utf8) + } + + mutating func commitAndPush() throws -> String { + let commitHash = try run(command: "git rev-parse HEAD", directory: buildDirectory)!.trimmingCharacters(in: .whitespacesAndNewlines) + let branch = try run(command: "git rev-parse --abbrev-ref HEAD", directory: buildDirectory)!.trimmingCharacters(in: .whitespacesAndNewlines) + + info("Pushing changes") + try run(command: "git add Package.swift") + try run(command: "git add Sources") + try run(command: "git commit -m 'Bump to version \(version) (matrix-rust-sdk/\(branch) \(commitHash))'") + try run(command: "git push") + + return commitHash + } + + func makeRelease(at commitHash: String, uploading zipFileURL: URL) async throws { + info("Making release") + let url = URL(string: "https://api.github.com/repos")! + .appending(path: packageRepo) + .appending(component: "releases") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.addValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content") + + let body = GitHubReleaseRequest(tagName: version, + targetCommitish: "main", + name: version, + body: "https://github.com/matrix-org/matrix-rust-sdk/tree/\(commitHash)", + draft: false, + prerelease: false, + generateReleaseNotes: false, + makeLatest: "true") + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let bodyData = try encoder.encode(body) + request.httpBody = bodyData + + let (data, _) = try await URLSession.shared.data(for: request) + let release = try JSONDecoder().decode(GitHubRelease.self, from: data) + + info("Release created \(release.htmlURL)") + + try await uploadFramework(at: zipFileURL, to: release.uploadURL) + } + + func uploadFramework(at fileURL: URL, to uploadURL: URL) async throws { + info("Uploading framework") + + var uploadComponents = URLComponents(url: uploadURL, resolvingAgainstBaseURL: false)! + uploadComponents.queryItems = [URLQueryItem(name: "name", value: fileURL.lastPathComponent)] + + var request = URLRequest(url: uploadComponents.url!) + request.httpMethod = "POST" + request.addValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.addValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/zip", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ReleaseError.httpResponse(-1) + } + guard httpResponse.statusCode == 201 else { + throw ReleaseError.httpResponse(httpResponse.statusCode) + } + + let upload = try JSONDecoder().decode(GitHubUploadResponse.self, from: data) + info("Upload finished \(upload.browserDownloadURL)") + } + + // MARK: Helpers + + private func info(_ message: String) { + print("🚀 \(message)") + } + + @discardableResult + private func run(command: String, directory: URL? = nil) throws -> String? { + let process = Process() + let outputPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-cu", command] + process.currentDirectoryURL = directory ?? packageDirectory + process.standardOutput = outputPipe + + try process.run() + process.waitUntilExit() + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + throw ReleaseError.commandFailure(command: command, directory: directory ?? packageDirectory) + } + + guard let outputData = try outputPipe.fileHandleForReading.readToEnd() else { return nil } + return String(data: outputData, encoding: .utf8) + } + + private func checksum(for fileURL: URL) throws -> String { + var hasher = SHA256() + let handle = try FileHandle(forReadingFrom: fileURL) + + while let bytes = try handle.read(upToCount: SHA256.blockByteCount) { + hasher.update(data: bytes) + } + + let digest = hasher.finalize() + return digest.map { String(format: "%02hhx", $0) }.joined() + } +} + +enum ReleaseError: Error { + case commandFailure(command: String, directory: URL) + case httpResponse(Int) +} + +// MARK: - GitHub Release https://docs.github.com/en/rest/releases/releases#create-a-release + +struct GitHubReleaseRequest: Encodable { + let tagName: String + let targetCommitish: String + let name: String + let body: String + let draft: Bool + let prerelease: Bool + let generateReleaseNotes: Bool + let makeLatest: String +} + +struct GitHubRelease: Decodable { + let htmlURL: URL + let uploadURLString: String // Decode as a string to avoid URL percent encoding. + + var uploadURL: URL { + URL(string: String(uploadURLString.split(separator: "{")[0]))! + } + + enum CodingKeys: String, CodingKey { + case htmlURL = "html_url" + case uploadURLString = "upload_url" + } +} + +struct GitHubUploadResponse: Decodable { + let browserDownloadURL: String + + enum CodingKeys: String, CodingKey { + case browserDownloadURL = "browser_download_url" + } +} diff --git a/Tools/Scripts/release.py b/Tools/Scripts/release.py deleted file mode 100644 index c5c4ea9..0000000 --- a/Tools/Scripts/release.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/python3 - -import os -import subprocess -import json -import argparse -from fileinput import FileInput -from pathlib import Path -import requests -import json -import netrc - -# Get the GitHub token from the user's .netrc -secrets = netrc.netrc() -username, account, github_token = secrets.authenticators('api.github.com') - -if github_token is None: - print("Please set api.github.com in your .netrc file.") - exit(1) - -parser = argparse.ArgumentParser() -parser.add_argument('--version', type=str, help='Version of the release', required=True) -parser.add_argument('--sdk_path', type=str, default='', help='Path of the matrix-rust-sdk repository (defaults to sibling matrix-rust-sdk folder)', required=False) - -args = vars(parser.parse_args()) - -def remove_suffix(string, suffix): - if string.endswith(suffix): - return string[:-len(suffix)] - return string - -# find root directory -root = remove_suffix(Path(os.path.abspath(os.path.dirname(__file__))).parent.parent.__str__(), '/') -version = args['version'] -sdk_path = str(args['sdk_path']) -if len(sdk_path) == 0: - sdk_path = remove_suffix(Path(root).parent.__str__(), '/') + '/matrix-rust-sdk' -else: - sdk_path = remove_suffix(os.path.realpath(sdk_path), '/') - -print("SDK path: " + sdk_path) -print("Generating framework") -os.system("(cd '" + sdk_path + "'; cargo xtask swift build-framework --release)") -sdk_generated_path = "/bindings/apple/generated" - -print("Copy generated files") -os.system("rsync -a '" + sdk_path + sdk_generated_path + "/swift/' '" + root + "/Sources/MatrixRustSDK'") -os.system("rm '" + root + "/Sources/MatrixRustSDK/sdk.swift'") - -print("Zipping framework") -zip_file_name = "MatrixSDKFFI.xcframework.zip" -os.system("pushd " + sdk_path + sdk_generated_path + "/; zip -r " + root + "/" + zip_file_name + " MatrixSDKFFI.xcframework; popd") - -print("Creating release") -checksum = subprocess.getoutput("shasum -a 256 " + root + "/" + zip_file_name).split()[0] - -with FileInput(files=[root + '/Package.swift'], inplace=True) as file: - for line in file: - line = line.rstrip() - if line.startswith('let checksum ='): - line = 'let checksum = "' + checksum + '"' - if line.startswith('let version ='): - line = 'let version = "' + version + '"' - print(line) - -sdk_commit_hash = subprocess.check_output("git rev-parse HEAD", shell=True, cwd=sdk_path).decode("utf-8").rstrip() -sdk_branch = subprocess.check_output("git rev-parse --abbrev-ref HEAD", shell=True, cwd=sdk_path).decode("utf-8").rstrip() -print("SDK commit: " + sdk_commit_hash) -commit_message = "Bump to " + version + " (matrix-rust-sdk/" + sdk_branch + " " + sdk_commit_hash + ")" -print("Pushing changes as: " + commit_message) -os.system("git add " + root + "/Package.swift") -os.system("git add " + root + "/Sources") -os.system("git commit -m '" + commit_message + "'") -os.system("git push") - -response1 = requests.post('https://api.github.com/repos/matrix-org/matrix-rust-components-swift/releases', -headers={ - 'Accept': 'application/vnd.github+json', - 'Authorization': 'Bearer ' + github_token, - 'Content-Type': 'application/x-www-form-urlencoded', -}, -data=json.dumps({ - "tag_name": version, - "target_commitish": "main", - "name": version, - "body": "https://github.com/matrix-org/matrix-rust-sdk/tree/" + sdk_commit_hash, - "draft": False, - "prerelease": False, - "generate_release_notes": False, - "make_latest": "true" -})) -creation_response = response1.json() -print("Release created: " + creation_response['html_url']) - -print("Uploading release assets") -upload_url = creation_response['upload_url'].split(u"{")[0] -with open(root + '/' + zip_file_name, 'rb') as file: - response2 = requests.post(upload_url, - headers={ - 'Accept': 'application/vnd.github+json', - 'Content-Type': 'application/zip', - 'Authorization': 'Bearer ' + github_token, - }, - params={'name': zip_file_name}, - data=file) - -if response2.status_code == 201: - upload_response = response2.json() - print("Upload finished: " + upload_response['browser_download_url'])