main.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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. print("Outcome is \(outcome)")
  223. return outcome
  224. }
  225. // This will commit and push to erase the entire remote spec repo.
  226. func eraseRemoteRepo(repoPath: String, from githubAccount: String, _ sdkRepoName: String,
  227. shell: Shell = Shell.shared) {
  228. shell
  229. .run(
  230. "git clone --quiet https://${BOT_TOKEN}@github.com/\(githubAccount)/\(sdkRepoName).git"
  231. )
  232. let fileManager = FileManager.default
  233. do {
  234. let dirs = try fileManager.contentsOfDirectory(atPath: "\(repoPath)/\(sdkRepoName)")
  235. for dir in dirs {
  236. if dir != ".git" {
  237. shell.run("cd \(sdkRepoName); git rm -r \(dir)")
  238. }
  239. }
  240. shell.run("cd \(sdkRepoName); git commit -m 'Empty repo'; git push")
  241. } catch {
  242. print("Error while enumerating files \(repoPath): \(error.localizedDescription)")
  243. }
  244. do {
  245. try fileManager.removeItem(at: URL(fileURLWithPath: "\(repoPath)/\(sdkRepoName)"))
  246. } catch {
  247. print("Error occurred while removing \(repoPath)/\(sdkRepoName): \(error)")
  248. }
  249. }
  250. mutating func run() throws {
  251. let fileManager = FileManager.default
  252. let curDir = FileManager().currentDirectoryPath
  253. var podSpecFiles: [String: URL] = [:]
  254. let documentsURL = URL(fileURLWithPath: sdkRepo)
  255. do {
  256. let fileURLs = try fileManager.contentsOfDirectory(
  257. at: documentsURL,
  258. includingPropertiesForKeys: nil
  259. )
  260. let podspecURLs = fileURLs.filter { $0.pathExtension == "podspec" }
  261. for podspecURL in podspecURLs {
  262. let podName = podspecURL.deletingPathExtension().lastPathComponent
  263. if !Constants.exclusivePods.contains(podName) {
  264. podSpecFiles[podName] = podspecURL
  265. }
  266. }
  267. } catch {
  268. print(
  269. "Error while enumerating files \(documentsURL.path): \(error.localizedDescription)"
  270. )
  271. }
  272. // This set is used to keep parent dependencies and help detect circular
  273. // dependencies.
  274. var tmpSet: Set<String> = []
  275. print("Detect podspecs: \(podSpecFiles.keys)")
  276. let specFileDict = SpecFiles(podSpecFiles, from: sdkRepo)
  277. generateOrderOfInstallation(
  278. pods: Array(podSpecFiles.keys),
  279. specFiles: specFileDict,
  280. parentDeps: &tmpSet
  281. )
  282. print("Podspec push order:\n", specFileDict.depInstallOrder.joined(separator: "->\t"))
  283. do {
  284. if fileManager.fileExists(atPath: "\(curDir)/\(sdkRepoName)") {
  285. print("remove \(sdkRepoName) dir.")
  286. try fileManager.removeItem(at: URL(fileURLWithPath: "\(curDir)/\(sdkRepoName)"))
  287. }
  288. eraseRemoteRepo(repoPath: "\(curDir)", from: githubAccount, sdkRepoName)
  289. } catch {
  290. print("error occurred. \(error)")
  291. }
  292. var exitCode: Int32 = 0
  293. var failedPods: [String] = []
  294. for pod in specFileDict.depInstallOrder {
  295. var podExitCode: Int32 = 0
  296. print("----------\(pod)-----------")
  297. switch pod {
  298. case "Firebase":
  299. podExitCode = pushPodspec(
  300. forPod: pod,
  301. sdkRepo: sdkRepo,
  302. sources: podSources,
  303. flags: Constants.umbrellaPodFlags
  304. )
  305. default:
  306. podExitCode = pushPodspec(
  307. forPod: pod,
  308. sdkRepo: sdkRepo,
  309. sources: podSources,
  310. flags: Constants.flags
  311. )
  312. }
  313. if podExitCode != 0 {
  314. exitCode = 1
  315. failedPods.append(pod)
  316. print("Failed pod - \(pod)")
  317. }
  318. }
  319. if exitCode != 0 {
  320. Self.exit(withError: SpecRepoBuilderError.failedToPush(pods: failedPods))
  321. }
  322. }
  323. }
  324. SpecRepoBuilder.main()