Skip to content

Instantly share code, notes, and snippets.

@masonmark
Created March 20, 2016 01:01
Show Gist options
  • Save masonmark/e007f99c37c4f59cf549 to your computer and use it in GitHub Desktop.
Save masonmark/e007f99c37c4f59cf549 to your computer and use it in GitHub Desktop.
A build script in ruby, then rewritten in swift
#!/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
#! /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