├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── ExyLib │ └── BuildGraph.swift ├── ExyWorkspace │ ├── Project.swift │ ├── Scheme.swift │ ├── Target.swift │ └── Workspace.swift └── exy │ └── main.swift └── Tests ├── ExyTests └── BuildGraphTests.swift └── ExyWorkspaceTests └── SchemeTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AEXML", 6 | "repositoryURL": "https://github.com/tadija/AEXML", 7 | "state": { 8 | "branch": null, 9 | "revision": "54bb8ea6fb693dd3f92a89e5fcc19e199fdeedd0", 10 | "version": "4.3.3" 11 | } 12 | }, 13 | { 14 | "package": "PathKit", 15 | "repositoryURL": "https://github.com/kylef/PathKit", 16 | "state": { 17 | "branch": null, 18 | "revision": "e2f5be30e4c8f531c9c1e8765aa7b71c0a45d7a0", 19 | "version": "0.9.2" 20 | } 21 | }, 22 | { 23 | "package": "Spectre", 24 | "repositoryURL": "https://github.com/kylef/Spectre.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "f14ff47f45642aa5703900980b014c2e9394b6e5", 28 | "version": "0.9.0" 29 | } 30 | }, 31 | { 32 | "package": "SwiftShell", 33 | "repositoryURL": "https://github.com/kareman/SwiftShell", 34 | "state": { 35 | "branch": null, 36 | "revision": "beebe43c986d89ea5359ac3adcb42dac94e5e08a", 37 | "version": "4.1.2" 38 | } 39 | }, 40 | { 41 | "package": "xcodeproj", 42 | "repositoryURL": "https://github.com/tuist/xcodeproj", 43 | "state": { 44 | "branch": null, 45 | "revision": "065f348754b6155b8037dc43876a8f2ee354b95d", 46 | "version": "6.7.0" 47 | } 48 | } 49 | ] 50 | }, 51 | "version": 1 52 | } 53 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Exy", 7 | products: [ 8 | .executable(name: "exy", targets: ["exy"]), 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/tuist/xcodeproj", from: "6.7.0"), 12 | ], 13 | targets: [ 14 | .target( 15 | name: "exy", 16 | dependencies: ["ExyLib"] 17 | ), 18 | .target( 19 | name: "ExyLib", 20 | dependencies: ["ExyWorkspace"] 21 | ), 22 | .testTarget( 23 | name: "ExyTests", 24 | dependencies: ["ExyLib"] 25 | ), 26 | .target( 27 | name: "ExyWorkspace", 28 | dependencies: ["xcodeproj"] 29 | ), 30 | .testTarget( 31 | name: "ExyWorkspaceTests", 32 | dependencies: ["ExyWorkspace"] 33 | ) 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `exy` – fast, reliable CI builds for Xcode workspaces 2 | ``` 3 | exy test --cache /path/to/cache MyApp.xcodeworkspace MyScheme \ 4 | -- -destination "name=iPhone X" -sdk iphonesimulator \ 5 | | tee xcodebuild.log \ 6 | | xcpretty 7 | ``` 8 | 9 | # This project is just a README at this point. The functionality described here is aspirational. 10 | 11 | ## Overview 12 | Xcode’s build system has a few building blocks: 13 | 14 | - _product_: something that’s built 15 | - _target_: instructions for building a _product_ 16 | - _scheme_: a collection of _targets_ 17 | - _project_: a collection of _targets_ and _schemes_ 18 | - _workspace_: a collection of _projects_ and _schemes_ 19 | 20 | These can be configured in a number of ways. But one arrangement is particularly powerful: a workspace of interdependent projects. By building through a workspace, you can choose between building all targets at once or building them individually. 21 | 22 | `exy` uses `xcodebuild` to build the projects in a workspace individually, making the products available to the projects that depend on them. But it caches products to avoid recompiling unchanged targets. This enables fast, reliable builds on CI—most of the speed of dirty builds with the reliability of clean builds. 23 | 24 | More details are available [below](#how-it-works). 25 | 26 | ## Usage 27 | `exy` can either (1) build a whole workspace at once or (2) build each target separately. The latter may perform better on sophisticated CI solutions that support caching and parallel build stages. 28 | 29 | ### Whole Workspace Builds 30 | `exy build` can build an entire scheme in a workspace. `exy test` can test an entire scheme and build anything that’s missing. That means you can either call `exy build` and then `exy test` if you want multiple stages or choose to only call `exy test` if you don’t care. 31 | 32 | The calls to `exy` are straightforward: 33 | 34 | ``` 35 | exy build --cache [-- [xcodebuild arguments]] 36 | 37 | exy test --cache [-- [xcodebuild arguments]] 38 | ``` 39 | 40 | In the normal case, the output will be the unaltered output from `xcodebuild`. But if an error occurs, it will also be printed. 41 | 42 | ### Parallel Builds 43 | Building in parallel is more complicated. The work that’s done by `exy build` and `exy test` is split into multiple stages; these stages must be understood and reassembled correctly. 44 | 45 | #### 1. Generate the Build Graph 46 | The initial stage is to generate the build graph—creating the list of schemes to build and the order in which they must be built. This can be used to parallelize the work across multiple machines. 47 | 48 | `exy graph ` will output a simple YAML document listing all the dependencies of each scheme. 49 | 50 | 51 | ``` 52 | » exy graph Example.xcworkspace ExampleApp 53 | Framework1: 54 | Framework2: 55 | - Framework1 56 | Framework3: 57 | - Framework1 58 | - Framework2 59 | Framework4: 60 | - Framework1 61 | ExampleApp: 62 | - Framework1 63 | - Framework2 64 | - Framework3 65 | - Framework4 66 | ``` 67 | 68 | Use this to split your CI into multiple jobs. 69 | 70 | You may need to create your build config before the CI job begins for your branch. If that’s the case, you should add a test to CI that verifies that this config is up to date. 71 | 72 | #### 2. Restore a Cached Version 73 | The speedups come from using cached products for some or all of the schemes. Use `exy key ` to calculate a cache key that can be used to locate built products for a given scheme. 74 | 75 | If you’re using a system with built-in caching—like CircleCI—then you may want to save this key to a file on disk. 76 | 77 | If you’re using an ordinary file system as a cache, you can use `exy restore ` to restore those files to the derived data directory. If the cache doesn’t contain the key, then `exy restore` will return a failed status; you can use this to decide whether to build and test the scheme. 78 | 79 | You can also `exy restore ` calculate the cache key and restore in a single command. 80 | 81 | You also need to restore the cached products for all of your dependencies. 82 | 83 | #### 2. Build the Scheme 84 | Actually building the scheme is easy: 85 | 86 | ``` 87 | exy build [-- [xcodebuild arguments]] 88 | ``` 89 | 90 | After building the scheme, you’ll need to save the products for the cache using the key from `exy key `. 91 | 92 | You can get a YAML document with the list of products to save using `exy products `: 93 | 94 | ``` 95 | » exy products Example.xcworkspace Framework1 96 | - path/to/Framework1.framework 97 | - path/to/Framework1.dSYM 98 | ``` 99 | 100 | And then you can save them using `exy save ... `. 101 | 102 | Or you can use `exy save ` to calculate the key and save the products all in one go. 103 | 104 | #### 3. Test the Scheme 105 | The scheme can be tested with `exy test`: 106 | 107 | ``` 108 | exy test [-- [xcodebuild arguments]] 109 | ``` 110 | 111 | You’ll need to restore the cached products for the scheme or build the scheme before you can run the tests. 112 | 113 | ### Miscellaneous Caching 114 | `exy`’s caching can also be used for other built products. Create a key for a set of inputs using `exy key [input files]`. Then use `exy save` and `exy restore` to copy the cache items to the correct location. 115 | 116 | ## How It Works 117 | The secret sauce of `exy` is calculating the build graph and calculating reliable cache keys. 118 | 119 | ### The Build Graph 120 | The build graph is built by reading the Xcode workspace and project files. Starting with the initial scheme, `exy` reads through projects to determine which frameworks they require and which projects provide those frameworks. This essentially duplicates Xcode’s _implicit dependencies_ feature. 121 | 122 | ### Cache Keys 123 | Cache keys are calculated from the SHAs of all the inputs to a given scheme. This is essentially equivalent to the way that Git uses SHAs to store trees and files. If the SHAs of all the inputs are unchanged, then the cache key for the scheme is also unchanged. 124 | 125 | `exy` can be conservative when calculating cache keys for performance reasons or to prevent false equivalencies. 126 | 127 | ## License 128 | `exy` is available under the MIT license. 129 | 130 | -------------------------------------------------------------------------------- /Sources/ExyLib/BuildGraph.swift: -------------------------------------------------------------------------------- 1 | import ExyWorkspace 2 | 3 | /// The graph of dependencies that need to be built. 4 | /// 5 | /// This is the output of `exy graph`. 6 | public struct BuildGraph: Hashable { 7 | private var dependencies: [Scheme: [Scheme]] 8 | 9 | public init(_ dependencies: [Scheme: [Scheme]]) { 10 | self.dependencies = dependencies 11 | } 12 | } 13 | 14 | extension BuildGraph: CustomStringConvertible { 15 | public var description: String { 16 | // A YAML document that lists the dependencies 17 | return dependencies 18 | .sorted { $0.key < $1.key } 19 | .map { edges in 20 | let scheme = "\(edges.key.name):" 21 | let dependencies = edges.value.map { " - \($0.name)" } 22 | return ([scheme] + dependencies).joined(separator: "\n") 23 | } 24 | .joined(separator: "\n") 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /Sources/ExyWorkspace/Project.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An Xcode project. 4 | public struct Project: Hashable { 5 | /// The file URL of the project. 6 | public var url: URL 7 | } 8 | 9 | extension Project { 10 | /// Information from an Xcode project 11 | public struct Info: Hashable { 12 | /// The project that this information describes. 13 | public var project: Project 14 | 15 | /// The schemes that are contained in the project. 16 | public var schemes: Set 17 | 18 | /// The targets in the project. 19 | public var targets: Set 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ExyWorkspace/Scheme.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An Xcode scheme. 4 | public struct Scheme: Hashable { 5 | /// The display name of the scheme. 6 | public var name: String 7 | 8 | /// The location of the scheme on disk. 9 | public var url: URL 10 | 11 | public init(name: String, url: URL) { 12 | self.name = name 13 | self.url = url 14 | } 15 | } 16 | 17 | extension Scheme: Comparable { 18 | public static func < (lhs: Scheme, rhs: Scheme) -> Bool { 19 | return lhs.name < rhs.name 20 | } 21 | } 22 | 23 | extension Scheme { 24 | /// Information about a scheme. 25 | public struct Info: Hashable { 26 | /// The scheme that this information describes. 27 | public var scheme: Scheme 28 | 29 | /// The targets that are built as part of this scheme. 30 | public var targets: Set 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /Sources/ExyWorkspace/Target.swift: -------------------------------------------------------------------------------- 1 | /// A target in an Xcode project. 2 | public struct Target: Hashable { 3 | /// The project that this target resides in. 4 | public var project: Project 5 | 6 | /// The display name of the target. 7 | public var name: String 8 | 9 | public init(project: Project, name: String) { 10 | self.project = project 11 | self.name = name 12 | } 13 | } 14 | 15 | extension Target { 16 | /// Information about a target. 17 | public struct Info: Hashable { 18 | /// The target that this information describes. 19 | public let target: Target 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Sources/ExyWorkspace/Workspace.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An Xcode workspace 4 | public struct Workspace: Hashable { 5 | /// The file URL of the workspace. 6 | public var url: URL 7 | } 8 | 9 | extension Workspace { 10 | public struct Info: Hashable { 11 | /// The workspace that this information describes. 12 | public var workspace: Workspace 13 | 14 | /// The schemes contained directly in the workspace. 15 | /// 16 | /// - note: This does not include schemes contained in the projects in the workspace. 17 | public var schemes: Set 18 | 19 | /// The projects in the workspace. 20 | public var projects: Set 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/exy/main.swift: -------------------------------------------------------------------------------- 1 | import ExyLib 2 | 3 | print("exy") 4 | -------------------------------------------------------------------------------- /Tests/ExyTests/BuildGraphTests.swift: -------------------------------------------------------------------------------- 1 | import ExyLib 2 | import ExyWorkspace 3 | import XCTest 4 | 5 | extension Scheme { 6 | private init(_ name: String) { 7 | self.init(name: name, url: URL(fileURLWithPath: "/\(name)")) 8 | } 9 | 10 | fileprivate static let A = Scheme("A") 11 | fileprivate static let B = Scheme("B") 12 | fileprivate static let C = Scheme("C") 13 | fileprivate static let D = Scheme("D") 14 | fileprivate static let E = Scheme("E") 15 | } 16 | 17 | final class BuildGraphDescriptionTests: XCTestCase { 18 | func test() { 19 | let graph = BuildGraph([ 20 | .A: [], 21 | .B: [.A], 22 | .C: [.A, .B], 23 | .D: [.A], 24 | .E: [.A, .B, .C, .D], 25 | ]) 26 | 27 | let expected = """ 28 | A: 29 | B: 30 | - A 31 | C: 32 | - A 33 | - B 34 | D: 35 | - A 36 | E: 37 | - A 38 | - B 39 | - C 40 | - D 41 | """ 42 | 43 | XCTAssertEqual(graph.description, expected) 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Tests/ExyWorkspaceTests/SchemeTests.swift: -------------------------------------------------------------------------------- 1 | import ExyWorkspace 2 | import XCTest 3 | 4 | final class SchemeLessThanTests: XCTestCase { 5 | func test() { 6 | let a = Scheme(name: "A", url: URL(fileURLWithPath: "/B")) 7 | let b = Scheme(name: "B", url: URL(fileURLWithPath: "/A")) 8 | 9 | XCTAssertLessThan(a, b) 10 | XCTAssertGreaterThan(b, a) 11 | } 12 | } 13 | 14 | --------------------------------------------------------------------------------