main.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. /*
  2. * Copyright 2020 Google LLC
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License");
  5. * you may not use this file except in compliance with the License.
  6. * You may obtain a copy of the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS,
  12. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. * See the License for the specific language governing permissions and
  14. * limitations under the License.
  15. */
  16. import ArgumentParser
  17. import Foundation
  18. private enum Constants {}
  19. extension Constants {
  20. static let specDependencyLabel = "dependency"
  21. static let skipLinesWithWords = ["unit_tests", "test_spec"]
  22. static let dependencyLineSeparators = CharacterSet(charactersIn: " ,/")
  23. static let podSources = [
  24. "https://${BOT_TOKEN}@github.com/FirebasePrivate/SpecsTesting",
  25. "https://github.com/firebase/SpecsStaging.git",
  26. "https://cdn.cocoapods.org/",
  27. ]
  28. static let exclusivePods: [String] = ["FirebaseSegmentation"]
  29. }
  30. // flags for 'pod push'
  31. extension Constants {
  32. static let flags = ["--skip-tests", "--allow-warnings"]
  33. static let umbrellaPodFlags = Constants.flags + ["--skip-import-validation", "--use-json"]
  34. }
  35. // SpecFiles is a wraper of dict mapping from required pods to their path. This
  36. // will also contain a sequence of installing podspecs.
  37. class SpecFiles {
  38. private var specFilesDict: [String: URL]
  39. var depInstallOrder: [String]
  40. var specSource: String
  41. init(_ specDict: [String: URL], from specSourcePath: String) {
  42. specFilesDict = specDict
  43. depInstallOrder = []
  44. specSource = specSourcePath
  45. }
  46. func removePod(_ key: String) {
  47. specFilesDict.removeValue(forKey: key)
  48. }
  49. subscript(key: String) -> URL? {
  50. return specFilesDict[key]
  51. }
  52. func contains(_ key: String) -> Bool {
  53. return specFilesDict[key] != nil
  54. }
  55. func isEmpty() -> Bool {
  56. return specFilesDict.isEmpty
  57. }
  58. }
  59. struct Shell {
  60. static let shared = Shell()
  61. @discardableResult
  62. func run(_ command: String, displayCommand: Bool = true,
  63. displayFailureResult: Bool = true) -> Int32 {
  64. let task = Process()
  65. let pipe = Pipe()
  66. task.standardOutput = pipe
  67. task.launchPath = "/bin/bash"
  68. task.arguments = ["-c", command]
  69. task.launch()
  70. if displayCommand {
  71. print("[SpecRepoBuilder] Command:\(command)\n")
  72. }
  73. task.waitUntilExit()
  74. let data = pipe.fileHandleForReading.readDataToEndOfFile()
  75. let log = String(data: data, encoding: .utf8)!
  76. if displayFailureResult, task.terminationStatus != 0 {
  77. print("-----Exit code: \(task.terminationStatus)")
  78. print("-----Log:\n \(log)")
  79. }
  80. return task.terminationStatus
  81. }
  82. }
  83. // Error types
  84. enum SpecRepoBuilderError: Error {
  85. // Error occurs when circular dependencies are detected and deps will be
  86. // displayed.
  87. case circularDependencies(pods: Set<String>)
  88. // Error occurs when there exist specs that failed to push to a spec repo. All
  89. // specs failed to push should be displayed.
  90. case failedToPush(pods: [String])
  91. // Error occurs when a podspec is not found in the repo.
  92. case podspecNotFound(_ podspec: String, from: String)
  93. }
  94. struct SpecRepoBuilder: ParsableCommand {
  95. @Option(help: "The root of the firebase-ios-sdk checked out git repo.")
  96. var sdkRepo: String = FileManager().currentDirectoryPath
  97. @Option(help: "A list of podspec sources in Podfiles.")
  98. var podSources: [String] = Constants.podSources
  99. @Option(help: "Podspecs that will not be pushed to repo.")
  100. var excludePods: [String] = Constants.exclusivePods
  101. @Option(help: "Github Account Name.")
  102. var githubAccount: String = "FirebasePrivate"
  103. @Option(help: "Github Repo Name.")
  104. var sdkRepoName: String = "SpecsTesting"
  105. @Option(help: "Local Podspec Repo Name.")
  106. var localSpecRepoName: String
  107. @Flag(help: "Raise error while circular dependency detected.")
  108. var raiseCircularDepError: Bool = false
  109. // This will track down dependencies of pods and keep the sequence of
  110. // dependency installation in specFiles.depInstallOrder.
  111. func generateOrderOfInstallation(pods: [String], specFiles: SpecFiles,
  112. parentDeps: inout Set<String>) {
  113. // pods are dependencies will be tracked down.
  114. // specFiles includes required pods and their URLs.
  115. // parentDeps will record the path of tracking down dependencies to avoid
  116. // duplications and circular dependencies.
  117. // Stop tracking down when the parent pod does not have any required deps.
  118. if pods.isEmpty {
  119. return
  120. }
  121. for pod in pods {
  122. guard specFiles.contains(pod) else { continue }
  123. let deps = getTargetedDeps(of: pod, from: specFiles)
  124. // parentDeps will have all dependencies the current pod supports. If the
  125. // current pod were in the parent dependencies, that means it was tracked
  126. // before and it is circular dependency.
  127. if parentDeps.contains(pod) {
  128. print("Circular dependency is detected in \(pod) and \(parentDeps)")
  129. if raiseCircularDepError {
  130. Self
  131. .exit(withError: SpecRepoBuilderError
  132. .circularDependencies(pods: parentDeps))
  133. }
  134. continue
  135. }
  136. // Record the pod as a parent and use depth-first-search to track down
  137. // dependencies of this pod.
  138. parentDeps.insert(pod)
  139. generateOrderOfInstallation(
  140. pods: deps,
  141. specFiles: specFiles,
  142. parentDeps: &parentDeps
  143. )
  144. // When pod does not have required dep or its required deps are recorded,
  145. // the pod itself will be recorded into the depInstallOrder.
  146. if !specFiles.depInstallOrder.contains(pod) {
  147. print("\(pod) depends on \(deps).")
  148. specFiles.depInstallOrder.append(pod)
  149. }
  150. // When track back from a lower level, parentDep should track back by
  151. // removing one pod.
  152. parentDeps.remove(pod)
  153. }
  154. }
  155. // Scan a podspec file and find and return all dependencies in this podspec.
  156. func searchDeps(ofPod podName: String, from podSpecFiles: SpecFiles) -> [String] {
  157. var deps: [String] = []
  158. var fileContents = ""
  159. guard let podSpecURL = podSpecFiles[podName] else {
  160. Self
  161. .exit(withError: SpecRepoBuilderError
  162. .podspecNotFound(podName, from: podSpecFiles.specSource))
  163. }
  164. do {
  165. fileContents = try String(contentsOfFile: podSpecURL.path, encoding: .utf8)
  166. } catch {
  167. fatalError("Could not read \(podName) podspec from \(podSpecURL.path).")
  168. }
  169. // Get all the lines containing `dependency` but don't contain words we
  170. // want to ignore.
  171. let depLines: [String] = fileContents
  172. .components(separatedBy: .newlines)
  173. .filter { $0.contains("dependency") }
  174. // Skip lines with words in Constants.skipLinesWithWords
  175. .filter { !Constants.skipLinesWithWords.contains(where: $0.contains)
  176. }
  177. for line in depLines {
  178. let newLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
  179. // This is to avoid pushing umbrellapods like Firebase/Core.
  180. let tokens = newLine.components(separatedBy: Constants.dependencyLineSeparators)
  181. if let depPrefix = tokens.first {
  182. if depPrefix.hasSuffix(Constants.specDependencyLabel) {
  183. // e.g. In Firebase.podspec, Firebase/Core will not be considered a
  184. // dependency.
  185. // "ss.dependency 'Firebase/Core'" will be splited in
  186. // ["ss.dependency", "'Firebase", "Core'"]
  187. let podNameRaw = String(tokens[1]).replacingOccurrences(of: "'", with: "")
  188. // In the example above, deps here will not include Firebase since
  189. // it is the same as the pod name.
  190. if podNameRaw != podName { deps.append(podNameRaw) }
  191. }
  192. }
  193. }
  194. return deps
  195. }
  196. // Filter and get a list of required dependencies found in the repo.
  197. func filterTargetDeps(_ deps: [String], with targets: SpecFiles) -> [String] {
  198. var targetedDeps: [String] = []
  199. for dep in deps {
  200. // Only get unique and required dep in the output list.
  201. if targets.contains(dep), !targetedDeps.contains(dep) {
  202. targetedDeps.append(dep)
  203. }
  204. }
  205. return targetedDeps
  206. }
  207. func getTargetedDeps(of pod: String, from specFiles: SpecFiles) -> [String] {
  208. let deps = searchDeps(ofPod: pod, from: specFiles)
  209. return filterTargetDeps(deps, with: specFiles)
  210. }
  211. func pushPodspec(forPod pod: String, sdkRepo: String, sources: [String],
  212. flags: [String], shell: Shell = Shell.shared) -> Int32 {
  213. let podPath = sdkRepo + "/" + pod + ".podspec"
  214. let sourcesArg = sources.joined(separator: ",")
  215. let flagsArg = flags.joined(separator: " ")
  216. let outcome =
  217. shell
  218. .run(
  219. "pod repo push \(localSpecRepoName) \(podPath) --sources=\(sourcesArg) \(flagsArg)"
  220. )
  221. shell.run("pod repo update")
  222. return outcome
  223. }
  224. // This will commit and push to erase the entire remote spec repo.
  225. func eraseRemoteRepo(repoPath: String, from githubAccount: String, _ sdkRepoName: String,
  226. shell: Shell = Shell.shared) {
  227. shell
  228. .run(
  229. "git clone --quiet https://${BOT_TOKEN}@github.com/\(githubAccount)/\(sdkRepoName).git"
  230. )
  231. let fileManager = FileManager.default
  232. do {
  233. let dirs = try fileManager.contentsOfDirectory(atPath: "\(repoPath)/\(sdkRepoName)")
  234. for dir in dirs {
  235. if dir != ".git" {
  236. shell.run("cd \(sdkRepoName); git rm -r \(dir)")
  237. }
  238. }
  239. shell.run("cd \(sdkRepoName); git commit -m 'Empty repo'; git push")
  240. } catch {
  241. print("Error while enumerating files \(repoPath): \(error.localizedDescription)")
  242. }
  243. do {
  244. try fileManager.removeItem(at: URL(fileURLWithPath: "\(repoPath)/\(sdkRepoName)"))
  245. } catch {
  246. print("Error occurred while removing \(repoPath)/\(sdkRepoName): \(error)")
  247. }
  248. }
  249. mutating func run() throws {
  250. let fileManager = FileManager.default
  251. let curDir = FileManager().currentDirectoryPath
  252. var podSpecFiles: [String: URL] = [:]
  253. let documentsURL = URL(fileURLWithPath: sdkRepo)
  254. do {
  255. let fileURLs = try fileManager.contentsOfDirectory(
  256. at: documentsURL,
  257. includingPropertiesForKeys: nil
  258. )
  259. let podspecURLs = fileURLs.filter { $0.pathExtension == "podspec" }
  260. for podspecURL in podspecURLs {
  261. let podName = podspecURL.deletingPathExtension().lastPathComponent
  262. if !Constants.exclusivePods.contains(podName) {
  263. podSpecFiles[podName] = podspecURL
  264. }
  265. }
  266. } catch {
  267. print(
  268. "Error while enumerating files \(documentsURL.path): \(error.localizedDescription)"
  269. )
  270. }
  271. // This set is used to keep parent dependencies and help detect circular
  272. // dependencies.
  273. var tmpSet: Set<String> = []
  274. print("Detect podspecs: \(podSpecFiles.keys)")
  275. let specFileDict = SpecFiles(podSpecFiles, from: sdkRepo)
  276. generateOrderOfInstallation(
  277. pods: Array(podSpecFiles.keys),
  278. specFiles: specFileDict,
  279. parentDeps: &tmpSet
  280. )
  281. print("Podspec push order:\n", specFileDict.depInstallOrder.joined(separator: "->\t"))
  282. do {
  283. if fileManager.fileExists(atPath: "\(curDir)/\(sdkRepoName)") {
  284. print("remove \(sdkRepoName) dir.")
  285. try fileManager.removeItem(at: URL(fileURLWithPath: "\(curDir)/\(sdkRepoName)"))
  286. }
  287. eraseRemoteRepo(repoPath: "\(curDir)", from: githubAccount, sdkRepoName)
  288. } catch {
  289. print("error occurred. \(error)")
  290. }
  291. var exitCode: Int32 = 0
  292. var failedPods: [String] = []
  293. for pod in specFileDict.depInstallOrder {
  294. var podExitCode: Int32 = 0
  295. print("----------\(pod)-----------")
  296. switch pod {
  297. case "Firebase":
  298. podExitCode = pushPodspec(
  299. forPod: pod,
  300. sdkRepo: sdkRepo,
  301. sources: podSources,
  302. flags: Constants.umbrellaPodFlags
  303. )
  304. default:
  305. podExitCode = pushPodspec(
  306. forPod: pod,
  307. sdkRepo: sdkRepo,
  308. sources: podSources,
  309. flags: Constants.flags
  310. )
  311. }
  312. if podExitCode != 0 {
  313. exitCode = 1
  314. failedPods.append(pod)
  315. }
  316. }
  317. if exitCode != 0 {
  318. Self.exit(withError: SpecRepoBuilderError.failedToPush(pods: failedPods))
  319. }
  320. }
  321. }
  322. SpecRepoBuilder.main()