Created
March 20, 2016 01:01
-
-
Save masonmark/e007f99c37c4f59cf549 to your computer and use it in GitHub Desktop.
A build script in ruby, then rewritten in swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# A script to clean-build all 3 targets in one step, and return success/failure (0/nonzero). | |
require 'pathname' | |
require 'fileutils' | |
require 'open3' | |
PATH_TO_ME = Pathname.new(File.expand_path(__FILE__)) | |
PATH_TO_ROOT = PATH_TO_ME.parent.parent | |
PATH_TO_SDK = PATH_TO_ROOT + "soracom-sdk-swift" | |
PATH_TO_IOS_APP = PATH_TO_ROOT + "Soracom-iOS-app" | |
PATH_TO_MAC_APP = PATH_TO_ROOT + "Soracom-API-Sandbox-Tool" | |
PATH_TO_SDK_PACKAGES = PATH_TO_SDK + "Packages" | |
class BuildHelper | |
def main | |
puts "" | |
puts "👹 Welcome!" | |
puts "" | |
puts "This script will attempt to clean build all the different targets in the project." | |
check_preconditions | |
build_swift_package | |
clean_build_ios_app | |
clean_build_mac_app | |
puts "\n✅ COMPLETED SUCCESSFULLY." | |
end | |
def check_preconditions | |
puts "Checking preconditions..." | |
failures = [] | |
failures << "Didn't find Swift 3 at: #{PATH_TO_SWIFT_3}" unless File.executable? PATH_TO_SWIFT_3 | |
failures << "Didn't find xcodebuild at: #{PATH_TO_XCODEBUILD}" unless File.executable? PATH_TO_XCODEBUILD | |
unsafe_packages = sdk_packages_with_uncommitted_changes | |
failures << "It's not safe to nuke Packages folder at: #{PATH_TO_SDK_PACKAGES}" unless unsafe_packages == [] | |
unsafe_packages.each do |package| | |
failures << " → #{package}" | |
end | |
unless %x[ swift -version ].include? "version 3" then | |
failures << "Didn't find the require Swift version (swift in path should be version 3.x)" | |
end | |
unless %x[ xcodebuild -version ].include? "Xcode 7.3" then | |
failures << "Didn't find the required xcodebuild version (xcodebuild in path should be version 7.3.x)" | |
end | |
if failures != [] | |
puts "PRECONDITIONS FAILED:" | |
failures.each {|f| puts f} | |
abort "OPERATION FAILED." | |
end | |
end | |
def sdk_packages_with_uncommitted_changes | |
exclude = [".", "..", ".DS_Store"] | |
problems = [] | |
Dir.foreach(PATH_TO_SDK_PACKAGES) do |name| | |
next if exclude.include? name | |
subpath = PATH_TO_SDK_PACKAGES + name | |
Dir.chdir subpath | |
stdout, stderr, status = Open3.capture3("git status --porcelain") | |
if stdout != "" || stderr != "" || status != 0 | |
problems << subpath | |
end | |
end | |
return problems | |
end | |
def build_swift_package | |
puts "Building SDK as Swift 3 package with 'swift build'..." | |
unless sdk_packages_with_uncommitted_changes == [] | |
abort "WTF: sdk_packages_with_uncommitted_changes != []" | |
end | |
FileUtils.remove_dir PATH_TO_SDK + "Packages" | |
Dir.chdir PATH_TO_SDK | |
stdout, stderr, status = Open3.capture3("swift build") | |
puts stdout unless stdout == "" | |
puts stderr unless stderr == "" | |
puts "Swift build exit status: #{status}" | |
if status != 0 | |
abort "OPERATION FAILED: swift build failed" | |
end | |
end | |
def clean_build_ios_app | |
puts "Building iOS app with xcodebuild..." | |
Dir.chdir PATH_TO_IOS_APP | |
stdout, stderr, status = Open3.capture3("xcodebuild clean build -configuration Debug -sdk iphonesimulator -alltargets") | |
puts "xcodebuild exit status: #{status}" | |
if status != 0 | |
puts "xcodebuild output:" | |
puts stdout | |
puts stderr | |
abort "OPERATION FAILED: xcodebuild build failed" | |
end | |
end | |
def clean_build_mac_app | |
puts "Building Mac app with xcodebuild..." | |
Dir.chdir PATH_TO_MAC_APP | |
stdout, stderr, status = Open3.capture3("xcodebuild clean build -configuration Debug -target 'Soracom API Sandbox Tool'") | |
# because xcodebuild pukes building .xctest bundle with code signing | |
puts "xcodebuild exit status: #{status}" | |
if status != 0 | |
puts "xcodebuild output:" | |
puts stdout | |
puts stderr | |
abort "OPERATION FAILED: xcodebuild build failed" | |
end | |
end | |
end # end class BuildHelper | |
if __FILE__ == $PROGRAM_NAME | |
BuildHelper.new.main | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /Library/Developer/Toolchains/swift-latest.xctoolchain/usr/bin/swift | |
//Mason 2016-03-19: Not reliable when Xcode using Swift 2 & Swift 3 prerelease installed: #! /usr/bin/env xcrun swift -F . | |
// NOTE: This is basically just an experimental Swift version of the Ruby build script. I wrote it just to see how Swift | |
// handles this kind of chore. Short answer: Not too badly, actually, though there are some glaring shortcomings. NSTask | |
// in particular is a terrible API. But strong typing, superb debugger, and robust autocomplete made writing this more | |
// pleasant than doing the ruby one, actually. | |
import Foundation | |
let pathToGit = "/usr/bin/git" // FIXME: unsafe; make robust | |
let pathToSwift = "/Library/Developer/Toolchains/swift-latest.xctoolchain/usr/bin/swift" | |
let pathToXcodebuild = "/usr/bin/xcodebuild" | |
let pathToRoot = "/Users/mason/Code/ios-client" | |
let pathToSDK = "/Users/mason/Code/ios-client/soracom-sdk-swift" | |
let pathToSDKPkgs = "/Users/mason/Code/ios-client/soracom-sdk-swift/Packages" | |
let pathToiOSApp = "/Users/mason/Code/ios-client/Soracom-iOS-app" | |
let pathToMacApp = "/Users/mason/Code/ios-client/Soracom-API-Sandbox-Tool" | |
enum BuildHelperExitCode: Int { | |
case Success = 0 | |
case ErrPreconditionsNotMet = 101 | |
case ErrUnsafeToNukeSDKPackagesDir = 102 | |
case ErrSwiftBuildFailed = 103 | |
case ErrXcodeBuildFailed = 104 | |
} | |
/// This guy automates a clean build of everything in the "ios-client" project. It does: | |
/// - Abort if soracom-sdk-swift/Packages is unclean | |
/// - Nuke soracom-sdk-swift/Packages | |
/// - 'swift build' soracom-sdk-swift | |
/// - 'xcodebuild clean build' iOS app | |
/// - 'xcodebuild clean build' Mac app | |
/// - report results at end | |
/// - exits with exit code | |
class BuildHelper { | |
func main() { | |
print("") | |
print("👹 Welcome!") | |
print("This script will attempt to clean build all the different targets in the project.") | |
print("Checking preconditions...") | |
let warnings = checkPreconditions() | |
guard warnings == [] else { | |
printList(warnings, heading: "Precondition checks failed:") | |
return exitWithCode(.ErrPreconditionsNotMet) | |
} | |
print("Checking for uncommitted changes to packages...") | |
let unsafePackages = packagesWithUncommittedChangesIn(pathToSDKPkgs) | |
guard unsafePackages == [] else { | |
printList(unsafePackages, heading: "Unsafe to remove packages, due to uncommitted changes:") | |
return exitWithCode(.ErrUnsafeToNukeSDKPackagesDir) | |
} | |
print("Building SDK as Swift 3 package with 'swift build'...") | |
let swiftBuild = Task(pathToSwift, arguments: ["build"], directory: pathToSDK) | |
guard swiftBuild.terminationStatus == 0 else { | |
print(swiftBuild) | |
return exitWithCode(.ErrUnsafeToNukeSDKPackagesDir) | |
} | |
print("Building iOS app with xcodebuild...") | |
let iOSBuild = Task(pathToXcodebuild, arguments: ["clean", "build", "-configuration", "Debug", "-sdk", "iphonesimulator"], directory: pathToiOSApp) | |
guard iOSBuild.terminationStatus == 0 else { | |
print(iOSBuild) | |
return exitWithCode(.ErrXcodeBuildFailed) | |
} | |
print("Building Mac app with xcodebuild...") | |
let macBuild = Task(pathToXcodebuild, arguments: ["clean", "build", "-configuration", "Debug", "-target", "Soracom API Sandbox Tool"], directory: pathToMacApp) | |
guard macBuild.terminationStatus == 0 else { | |
print(macBuild) | |
return exitWithCode(.ErrXcodeBuildFailed) | |
} | |
exitWithCode(.Success) | |
} | |
func printList(list: [String], heading: String? = nil) { | |
if let heading = heading { | |
print(heading) | |
} | |
let separator = "\n - " | |
print(separator, terminator: "") | |
print(list.joinWithSeparator(separator)) | |
} | |
/// Return [] if all checks pass, otherwise a list of strings telling the user what is wrong. | |
func checkPreconditions() -> [String] { | |
return [] | |
} | |
/// Print result and exit program. | |
func exitWithCode(code: BuildHelperExitCode) { | |
print(code == .Success ? "✅ COMPLETED SUCCESSFULLY." : "💀 FAILED.") | |
print("Exiting with exit status: \(code)") | |
switch code { | |
default: | |
exit(Int32(code.rawValue)) | |
} | |
} | |
/// This tool nukes everything in the "Packages" subdirectory of the "soracom-sdk-swift" subdir. So, it tries to find things that are not safe to delete. It considers a git repo with no uncommitted changes safe. NOTE: THIS IS A FUZZY TEST AND NOT REALLY ACTUALLY THAT SAFE. I should fix this to move repos to the Trash instead of deleting. | |
func packagesWithUncommittedChangesIn(path: String) -> [String]{ | |
var result: [String] = [] | |
let fm = NSFileManager() | |
let contents: [String] | |
if !fm.fileExistsAtPath(path) { | |
// already nuked, presumably by user | |
return [] | |
} | |
do { | |
contents = try fm.contentsOfDirectoryAtPath(path) | |
} catch { | |
let err = error as NSError | |
result.append("ERROR: Can't get contents at path \(path): \(err.localizedDescription)") | |
// hacky but fuck it for now | |
return result | |
} | |
let excluded = [".", "..", ".DS_Store"] | |
for name in contents { | |
if excluded.contains(name) { | |
continue | |
} | |
let subpath = pathToSDKPkgs + "/" + name | |
let thisResult = isClean(subpath) | |
if !thisResult.isClean { | |
// result.append("\(subpath):\n\(thisResult.output)") | |
result.append(subpath + "\n" + thisResult.output) | |
} | |
} | |
return result | |
} | |
/// Returns true if there are no changes detected, and the Git repo at `path` seems safe to delete. ⚠️ Note that edge cases are not handled (e.g., repo has stashed changes, or un-pushed changes on a different branch). This is just intended to prevent the script from deleting a checked-out dependency in a swift package's "Packages" directory that has had changes made to it. | |
func isClean(path: String, verbose: Bool = true) -> (isClean: Bool, output: String) { | |
// git diff --exit-code was one idea, but that only checks for changes to tracked files, not deleted files nor new files | |
let check1 = Task.run(pathToGit, arguments: ["status", "--porcelain"], directory: path) | |
var isClean = check1.combinedOutput == "" && check1.terminationStatus == 0 | |
var output = check1.combinedOutput | |
if isClean { | |
// OK, nothing is un-committed, but let's try to check if we have local changes committed that haven't been pushed: | |
let check2 = Task.run(pathToGit, arguments: ["status"], directory: path) | |
if check2.stdoutText.containsString("ahead") { // hack attack! | |
isClean = false | |
output = check2.combinedOutput | |
} | |
} | |
return (isClean: isClean, output: output) | |
// let git = Task(pathToGit, arguments: ["status", "--porcelain"], directory: path) | |
// | |
// let stdout = git.stdoutText | |
// let stderr = git.stderrText | |
// | |
// let emptyOutput = stderr == "" && stdout == "" | |
// let zeroExit = git.terminationStatus == 0 | |
// let result = emptyOutput && zeroExit | |
// | |
// if (result) { | |
// // OK, nothing is un-committed, but let's try to check if we have local changes committed that haven't been pushed: | |
// | |
// let secondResult = Task.run(pathToGit, arguments: ["status"], directory: path) | |
// if secondResult.stdoutText.containsString("ahead") { // hack attack! | |
// return (isClean: false, output: secondResult.stdoutText) | |
// } | |
// } | |
// | |
// return (isClean: result, output: stdout + stderr) | |
} | |
} | |
// BEGIN Mason.Task (4.0.0) | |
/// A convenience struct containing the results of running a task. | |
public struct TaskResult { | |
public let stdoutText: String | |
public let stderrText: String | |
public let terminationStatus: Int | |
public var combinedOutput: String { | |
return stdoutText + stderrText | |
} | |
} | |
/// A simple wrapper for NSTask, to synchronously run external commands. | |
/// | |
/// **Note:** The API provided by NSTask is pretty shitty, and inherently dangerous in Swift. There are many cases where it will throw Objective-C exceptions in response to ordinary, predictable error situations, and this will either crash the program, or at least un-catchably intterup execution and create undefined behavior, unless the program implements some kind of [legacy exception-handling mechanism](https://github.com/masonmark/CatchObjCException#catchobjcexception) (which can only be done in Objective-C, not Swift, at least as of this writing on 2016-03-19). | |
/// | |
/// A non-exhaustive list: | |
/// - providing a bogus path for `launchPath` | |
/// - setting the `cwd` property to a path that doesn't point to a directory | |
/// - calling `launch()` more than once | |
/// - reading `terminationStatus` before the task has actually terminated | |
/// | |
/// Protecting aginst all this fuckery is beyond the scope of this class (and this lifetime), so... be careful! (And complain to Apple.) | |
public class Task: CustomStringConvertible { | |
public var launchPath = "/bin/ls" | |
public var cwd: String? = nil | |
public var arguments: [String] = [] | |
public var stdoutData = NSMutableData() | |
public var stderrData = NSMutableData() | |
public var stdoutText: String { | |
if let text = String(data: stdoutData, encoding: NSUTF8StringEncoding) { | |
return text | |
} else { | |
return "" | |
} | |
} | |
public var stderrText: String { | |
if let text = String(data: stderrData, encoding: NSUTF8StringEncoding) { | |
return text | |
} else { | |
return "" | |
} | |
} | |
var task: NSTask = NSTask() | |
/// The required initialize does nothing, so you must set up all the instance's values yourself. | |
public required init() { | |
// this exists just to satisfy the swift compiler | |
} | |
/// This convenience initializer is for when you want to construct a task instance and keep it around. | |
public convenience init(_ launchPath: String, arguments: [String] = [], directory: String? = nil, launch: Bool = true) { | |
self.init() | |
self.launchPath = launchPath | |
self.arguments = arguments | |
self.cwd = directory | |
if launch { | |
self.launch() | |
} | |
} | |
/// This convenience method is for when you just want to run an external command and get the results back. Use it like this: | |
/// | |
/// let results = Task.run("ping", arguments: ["-c", "10", "masonmark.com"]) | |
/// print(results.stdoutText) | |
public static func run (launchPath: String, arguments: [String] = [], directory: String? = nil) -> TaskResult { | |
let t = self.init() | |
// Can't use convenience init because: "Constructing an object... with a metatype value must use a 'required' initializer." | |
t.launchPath = launchPath | |
t.arguments = arguments | |
t.cwd = directory | |
t.launch() | |
return TaskResult(stdoutText: t.stdoutText, stderrText: t.stderrText, terminationStatus: t.terminationStatus) | |
} | |
/// Synchronously launch the underlying NSTask and wait for it to exit. | |
public func launch() { | |
task = NSTask() | |
if let cwd = cwd { | |
task.currentDirectoryPath = cwd | |
} | |
task.launchPath = launchPath | |
task.arguments = arguments | |
let stdoutPipe = NSPipe() | |
let stderrPipe = NSPipe() | |
task.standardOutput = stdoutPipe | |
task.standardError = stderrPipe | |
let stdoutHandle = stdoutPipe.fileHandleForReading | |
let stderrHandle = stderrPipe.fileHandleForReading | |
let dataReadQueue = dispatch_queue_create("com.masonmark.Mason.swift.Task.readQueue", DISPATCH_QUEUE_SERIAL) | |
stdoutHandle.readabilityHandler = { [unowned self] (fh) in | |
dispatch_sync(dataReadQueue) { | |
let data = fh.availableData | |
self.stdoutData.appendData(data) | |
} | |
} | |
stderrHandle.readabilityHandler = { [unowned self] (fh) in | |
dispatch_sync(dataReadQueue) { | |
let data = fh.availableData | |
self.stderrData.appendData(data) | |
} | |
} | |
// Mason 2016-03-19: The handlers above get invoked on their own threads. At first, since we just block this | |
// thread in a usleep loop until finished, I thought it was OK not to have any locking/synchronization around the | |
// reading data and appending it to stdoutText and stderrText. But in the debugger, I verified that there is | |
// sometimes a last read necessary after task.running returns false. This theoretically means that there could be | |
// a race condition where the last readability handler was just starting to execute in a different thread, while | |
// execution in this thread moved into the final read-filehandle-and-append-data operation. So now those reads | |
// and writes are all wrapped in dispatch_sync() and execute on the serial queue created above. | |
task.launch() | |
while task.running { | |
// If you don't read here, buffers can fill up with a lot of output (> 8K?), and deadlock, where the normal | |
// read methods block forever. But the readabilityHandler blocks we attached above will handle it, so here | |
// we just wait for the task to end. | |
usleep(100000) | |
} | |
stdoutHandle.readabilityHandler = nil | |
stderrHandle.readabilityHandler = nil | |
// Mason 2016-03-19: Just confirmed in debugger that there may still be data waiting in the buffers; readabilityHandler apparently not guaranteed to exhaust data before NSTask exits running state. So: | |
dispatch_sync(dataReadQueue) { | |
self.stdoutData.appendData(stdoutHandle.readDataToEndOfFile()) | |
self.stderrData.appendData(stderrHandle.readDataToEndOfFile()) | |
} | |
} | |
/// Returns the underlying NSTask instance's `terminationStatus`. Note: NSTask will raise an Objective-C exception if you call this before the task has actually terminated. | |
public var terminationStatus: Int { | |
return Int(task.terminationStatus) | |
} | |
public var description: String { | |
var result = ">>++++++++++++++++++++++++++++++++++++++++++++++++++++>>\n" | |
result += "COMMAND: \(launchPath) \(arguments.joinWithSeparator(" "))\n" | |
result += "TERMINATION STATUS: \(terminationStatus)\n" | |
result += "STDOUT: \(stdoutText)\n" | |
result += "STDERR: \(stderrText)\n" | |
result += "<<++++++++++++++++++++++++++++++++++++++++++++++++++++<<" | |
return result | |
} | |
} | |
// END Mason.Task (4.0.0) | |
// MARK: Main function | |
BuildHelper().main() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment