main.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  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/Firebase/SpecsTesting",
  25. "https://github.com/firebase/SpecsStaging.git",
  26. // https://cdn.cocoapods.org is not used here since `--update-sources`
  27. // will update spec repos before a spec is pushed, but cdn is not a spec
  28. // repo.
  29. "https://github.com/CocoaPods/Specs.git",
  30. ]
  31. }
  32. // flags for 'pod push'
  33. extension Constants {
  34. static let flags = [
  35. "--skip-tests",
  36. "--skip-import-validation",
  37. "--update-sources",
  38. ]
  39. static let umbrellaPodFlags = Constants.flags + ["--use-json"]
  40. }
  41. public extension Date {
  42. func dateTimeString() -> String {
  43. let formatter = DateFormatter()
  44. formatter.dateStyle = .short
  45. formatter.timeStyle = .medium
  46. return formatter.string(from: self)
  47. }
  48. func formattedDurationSince(_ date: Date) -> String {
  49. let formatter = DateComponentsFormatter()
  50. formatter.unitsStyle = .abbreviated
  51. formatter.allowedUnits = [.hour, .minute, .second]
  52. let secondsSinceDate = date.timeIntervalSince(self)
  53. return formatter.string(from: secondsSinceDate) ?? "\(round(secondsSinceDate)) sec"
  54. }
  55. }
  56. // SpecFiles is a wrapper of dict mapping from required pods to their path. This
  57. // will also contain a sequence of installing podspecs.
  58. class SpecFiles {
  59. private var specFilesDict: [String: URL]
  60. var depInstallOrder: [String]
  61. var specSource: String
  62. init(_ specDict: [String: URL], from specSourcePath: String) {
  63. specFilesDict = specDict
  64. depInstallOrder = []
  65. specSource = specSourcePath
  66. }
  67. func removePod(_ key: String) {
  68. specFilesDict.removeValue(forKey: key)
  69. }
  70. subscript(key: String) -> URL? {
  71. return specFilesDict[key]
  72. }
  73. func contains(_ key: String) -> Bool {
  74. return specFilesDict[key] != nil
  75. }
  76. func isEmpty() -> Bool {
  77. return specFilesDict.isEmpty
  78. }
  79. }
  80. struct Shell {
  81. static let shared = Shell()
  82. @discardableResult
  83. func run(_ command: String, displayCommand: Bool = true,
  84. displayFailureResult: Bool = true) throws -> Int32 {
  85. let task = Process()
  86. let pipe = Pipe()
  87. task.standardOutput = pipe
  88. task.executableURL = URL(fileURLWithPath: "/bin/zsh")
  89. task.arguments = ["-c", command]
  90. try task.run()
  91. if displayCommand {
  92. print("[SpecRepoBuilder] Command:\(command)\n")
  93. }
  94. task.waitUntilExit()
  95. let data = pipe.fileHandleForReading.readDataToEndOfFile()
  96. let log = String(data: data, encoding: .utf8)!
  97. if displayFailureResult, task.terminationStatus != 0 {
  98. print("-----Exit code: \(task.terminationStatus)")
  99. print("-----Log:\n \(log)")
  100. }
  101. return task.terminationStatus
  102. }
  103. }
  104. // Error types
  105. enum SpecRepoBuilderError: Error {
  106. // Error occurs when circular dependencies are detected and deps will be
  107. // displayed.
  108. case circularDependencies(pods: Set<String>)
  109. // Error occurs when there exist specs that failed to push to a spec repo. All
  110. // specs failed to push should be displayed.
  111. case failedToPush(pods: [String])
  112. // Error occurs when a podspec is not found in the repo.
  113. case podspecNotFound(_ podspec: String, from: String)
  114. // Error occurs when a directory path cannot be determined.
  115. case pathNotFound(_ path: String)
  116. }
  117. struct SpecRepoBuilder: ParsableCommand {
  118. @Option(help: "The root of the firebase-ios-sdk checked out git repo.")
  119. var sdkRepo: String = FileManager().currentDirectoryPath
  120. @Option(parsing: .upToNextOption, help: "A list of podspec sources in Podfiles.")
  121. var podSources: [String] = Constants.podSources
  122. @Option(parsing: .upToNextOption, help: "Podspecs that will not be pushed to repo.")
  123. var excludePods: [String] = []
  124. @Option(help: "GitHub Account Name.")
  125. var githubAccount: String = "Firebase"
  126. @Option(help: "GitHub Repo Name.")
  127. var sdkRepoName: String = "SpecsTesting"
  128. @Option(help: "Local Podspec Repo Name.")
  129. var localSpecRepoName: String
  130. @Option(parsing: .upToNextOption, help: "Push selected podspecs.")
  131. var includePods: [String] = []
  132. @Flag(help: "Keep or erase a repo before push.")
  133. var keepRepo: Bool = false
  134. @Flag(help: "Raise error while circular dependency detected.")
  135. var raiseCircularDepError: Bool = false
  136. @Flag(help: "Allow warnings when push a spec.")
  137. var allowWarnings: Bool = false
  138. // This will track down dependencies of pods and keep the sequence of
  139. // dependency installation in specFiles.depInstallOrder.
  140. func generateOrderOfInstallation(pods: [String], specFiles: SpecFiles,
  141. parentDeps: inout Set<String>) {
  142. // pods are dependencies will be tracked down.
  143. // specFiles includes required pods and their URLs.
  144. // parentDeps will record the path of tracking down dependencies to avoid
  145. // duplications and circular dependencies.
  146. // Stop tracking down when the parent pod does not have any required deps.
  147. if pods.isEmpty {
  148. return
  149. }
  150. for pod in pods {
  151. guard specFiles.contains(pod) else { continue }
  152. let deps = getTargetedDeps(of: pod, from: specFiles)
  153. // parentDeps will have all dependencies the current pod supports. If the
  154. // current pod were in the parent dependencies, that means it was tracked
  155. // before and it is circular dependency.
  156. if parentDeps.contains(pod) {
  157. print("Circular dependency is detected in \(pod) and \(parentDeps)")
  158. if raiseCircularDepError {
  159. Self
  160. .exit(withError: SpecRepoBuilderError
  161. .circularDependencies(pods: parentDeps))
  162. }
  163. continue
  164. }
  165. // Record the pod as a parent and use depth-first-search to track down
  166. // dependencies of this pod.
  167. parentDeps.insert(pod)
  168. generateOrderOfInstallation(
  169. pods: deps,
  170. specFiles: specFiles,
  171. parentDeps: &parentDeps
  172. )
  173. // When pod does not have required dep or its required deps are recorded,
  174. // the pod itself will be recorded into the depInstallOrder.
  175. if !specFiles.depInstallOrder.contains(pod) {
  176. print("\(pod) depends on \(deps).")
  177. specFiles.depInstallOrder.append(pod)
  178. }
  179. // When track back from a lower level, parentDep should track back by
  180. // removing one pod.
  181. parentDeps.remove(pod)
  182. }
  183. }
  184. // Scan a podspec file and find and return all dependencies in this podspec.
  185. func searchDeps(ofPod podName: String, from podSpecFiles: SpecFiles) -> [String] {
  186. var deps: [String] = []
  187. var fileContents = ""
  188. guard let podSpecURL = podSpecFiles[podName] else {
  189. Self
  190. .exit(withError: SpecRepoBuilderError
  191. .podspecNotFound(podName, from: podSpecFiles.specSource))
  192. }
  193. do {
  194. fileContents = try String(contentsOfFile: podSpecURL.path, encoding: .utf8)
  195. } catch {
  196. fatalError("Could not read \(podName) podspec from \(podSpecURL.path).")
  197. }
  198. // Get all the lines containing `dependency` but don't contain words we
  199. // want to ignore.
  200. let depLines: [String] = fileContents
  201. .components(separatedBy: .newlines)
  202. .filter { $0.contains("dependency") }
  203. // Skip lines with words in Constants.skipLinesWithWords
  204. .filter { !Constants.skipLinesWithWords.contains(where: $0.contains)
  205. }
  206. for line in depLines {
  207. let newLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
  208. // This is to avoid pushing umbrellapods like Firebase/Core.
  209. let tokens = newLine.components(separatedBy: Constants.dependencyLineSeparators)
  210. if let depPrefix = tokens.first {
  211. if depPrefix.hasSuffix(Constants.specDependencyLabel) {
  212. // e.g. In Firebase.podspec, Firebase/Core will not be considered a
  213. // dependency.
  214. // "ss.dependency 'Firebase/Core'" will be split in
  215. // ["ss.dependency", "'Firebase", "Core'"]
  216. let podNameRaw = String(tokens[1]).replacingOccurrences(of: "'", with: "")
  217. // In the example above, deps here will not include Firebase since
  218. // it is the same as the pod name.
  219. if podNameRaw != podName { deps.append(podNameRaw) }
  220. }
  221. }
  222. }
  223. return deps
  224. }
  225. // Filter and get a list of required dependencies found in the repo.
  226. func filterTargetDeps(_ deps: [String], with targets: SpecFiles) -> [String] {
  227. var targetedDeps: [String] = []
  228. for dep in deps {
  229. // Only get unique and required dep in the output list.
  230. if targets.contains(dep), !targetedDeps.contains(dep) {
  231. targetedDeps.append(dep)
  232. }
  233. }
  234. return targetedDeps
  235. }
  236. func getTargetedDeps(of pod: String, from specFiles: SpecFiles) -> [String] {
  237. let deps = searchDeps(ofPod: pod, from: specFiles)
  238. return filterTargetDeps(deps, with: specFiles)
  239. }
  240. func pushPodspec(forPod pod: URL, sdkRepo: String, sources: [String],
  241. flags: [String], shell: Shell = Shell.shared) throws -> Int32 {
  242. let sourcesArg = sources.joined(separator: ",")
  243. let flagsArgArr = allowWarnings ? flags + ["--allow-warnings"] : flags
  244. let flagsArg = flagsArgArr.joined(separator: " ")
  245. do {
  246. // Update the repo
  247. try shell.run("pod repo update")
  248. var isDir: ObjCBool = true
  249. let podName = pod.deletingPathExtension().lastPathComponent
  250. let homeDirURL = FileManager.default.homeDirectoryForCurrentUser
  251. let theProjectPath = "\(homeDirURL.path)/.cocoapods/repos/\(localSpecRepoName)/\(podName)"
  252. print("check project path \(theProjectPath)")
  253. if !FileManager.default.fileExists(atPath: theProjectPath, isDirectory: &isDir) {
  254. let outcome =
  255. try shell
  256. .run(
  257. "pod repo push \(localSpecRepoName) \(pod.path) --sources=\(sourcesArg) \(flagsArg)"
  258. )
  259. try shell.run("pod repo update")
  260. print("Outcome is \(outcome)")
  261. return outcome
  262. }
  263. print("`pod repo push` \(podName) will not run since the repo was uploaded already.")
  264. return 0
  265. } catch {
  266. throw error
  267. }
  268. }
  269. // This will commit and push to erase the entire remote spec repo.
  270. func eraseRemoteRepo(repoPath: String, from githubAccount: String, _ sdkRepoName: String,
  271. shell: Shell = Shell.shared) throws {
  272. do {
  273. try shell
  274. .run(
  275. "git clone --quiet https://${BOT_TOKEN}@github.com/\(githubAccount)/\(sdkRepoName).git"
  276. )
  277. } catch {
  278. throw error
  279. }
  280. let fileManager = FileManager.default
  281. do {
  282. let sdk_repo_path = "\(repoPath)/\(sdkRepoName)"
  283. print("The repo path is \(sdk_repo_path)")
  284. guard let repo_url = URL(string: sdk_repo_path) else {
  285. print("Error: cannot find \(sdk_repo_path).")
  286. Self
  287. .exit(withError: SpecRepoBuilderError
  288. .pathNotFound(sdk_repo_path))
  289. }
  290. // Skip hidden files, e.g. /.git
  291. let dirs = try fileManager.contentsOfDirectory(
  292. at: repo_url,
  293. includingPropertiesForKeys: nil,
  294. options: [.skipsHiddenFiles]
  295. )
  296. print("Found following unhidden dirs: \(dirs)")
  297. for dir in dirs {
  298. guard let isDir = try (dir.resourceValues(forKeys: [.isDirectoryKey])).isDirectory else {
  299. print("Error: cannot determine if \(dir.path) is a directory or not.")
  300. Self
  301. .exit(withError: SpecRepoBuilderError
  302. .pathNotFound(dir.path))
  303. }
  304. if isDir {
  305. print("Removing \(dir.path)")
  306. try shell.run("cd \(sdkRepoName); git rm -r \(dir.path)")
  307. }
  308. }
  309. do {
  310. try shell.run("cd \(sdkRepoName); git commit -m 'Empty repo'; git push")
  311. } catch {
  312. throw error
  313. }
  314. } catch {
  315. print("Error while enumerating files \(repoPath): \(error.localizedDescription)")
  316. }
  317. do {
  318. try fileManager.removeItem(at: URL(fileURLWithPath: "\(repoPath)/\(sdkRepoName)"))
  319. } catch {
  320. print("Error occurred while removing \(repoPath)/\(sdkRepoName): \(error)")
  321. }
  322. }
  323. mutating func run() throws {
  324. let fileManager = FileManager.default
  325. let curDir = FileManager().currentDirectoryPath
  326. var podSpecFiles: [String: URL] = [:]
  327. let documentsURL = URL(fileURLWithPath: sdkRepo)
  328. do {
  329. let fileURLs = try fileManager.contentsOfDirectory(
  330. at: documentsURL,
  331. includingPropertiesForKeys: nil
  332. )
  333. let podspecURLs = fileURLs
  334. .filter { $0.pathExtension == "podspec" || $0.pathExtension == "json" }
  335. for podspecURL in podspecURLs {
  336. print(podspecURL)
  337. let podName = podspecURL.lastPathComponent.components(separatedBy: ".")[0]
  338. print("Podspec, \(podName), is detected.")
  339. if excludePods.contains(podName) {
  340. continue
  341. }
  342. podSpecFiles[podName] = podspecURL
  343. }
  344. } catch {
  345. print(
  346. "Error while enumerating files \(documentsURL.path): \(error.localizedDescription)"
  347. )
  348. throw error
  349. }
  350. // This set is used to keep parent dependencies and help detect circular
  351. // dependencies.
  352. var tmpSet: Set<String> = []
  353. print("Detect podspecs: \(podSpecFiles.keys)")
  354. let specFileDict = SpecFiles(podSpecFiles, from: sdkRepo)
  355. generateOrderOfInstallation(
  356. pods: includePods.isEmpty ? Array(podSpecFiles.keys) : includePods,
  357. specFiles: specFileDict,
  358. parentDeps: &tmpSet
  359. )
  360. print("Podspec push order:\n", specFileDict.depInstallOrder.joined(separator: "->\t"))
  361. if !keepRepo {
  362. do {
  363. if fileManager.fileExists(atPath: "\(curDir)/\(sdkRepoName)") {
  364. print("remove \(sdkRepoName) dir.")
  365. try fileManager.removeItem(at: URL(fileURLWithPath: "\(curDir)/\(sdkRepoName)"))
  366. }
  367. try eraseRemoteRepo(repoPath: "\(curDir)", from: githubAccount, sdkRepoName)
  368. } catch {
  369. print("error occurred. \(error)")
  370. throw error
  371. }
  372. }
  373. var exitCode: Int32 = 0
  374. var failedPods: [String] = []
  375. let startDate = Date()
  376. var minutes = 0
  377. for pod in specFileDict.depInstallOrder {
  378. print("----------\(pod)-----------")
  379. let timer: DispatchSourceTimer = {
  380. let t = DispatchSource.makeTimerSource()
  381. t.schedule(deadline: .now(), repeating: 60)
  382. t.setEventHandler(handler: {
  383. print("Tests have run \(minutes) min(s).")
  384. minutes += 1
  385. })
  386. return t
  387. }()
  388. timer.resume()
  389. var podExitCode: Int32 = 0
  390. do {
  391. guard let podURL = specFileDict[pod] else {
  392. Self
  393. .exit(withError: SpecRepoBuilderError
  394. .podspecNotFound(pod, from: sdkRepo))
  395. }
  396. switch pod {
  397. case "Firebase":
  398. podExitCode = try pushPodspec(
  399. forPod: podURL,
  400. sdkRepo: sdkRepo,
  401. sources: podSources,
  402. flags: Constants.umbrellaPodFlags
  403. )
  404. default:
  405. podExitCode = try pushPodspec(
  406. forPod: podURL,
  407. sdkRepo: sdkRepo,
  408. sources: podSources,
  409. flags: Constants.flags
  410. )
  411. }
  412. if podExitCode != 0 {
  413. exitCode = 1
  414. failedPods.append(pod)
  415. print("Failed pod - \(pod)")
  416. }
  417. } catch {
  418. throw error
  419. }
  420. timer.cancel()
  421. let finishDate = Date()
  422. print("\(pod) is finished at: \(finishDate.dateTimeString()). " +
  423. "Duration: \(startDate.formattedDurationSince(finishDate))")
  424. }
  425. if exitCode != 0 {
  426. Self.exit(withError: SpecRepoBuilderError.failedToPush(pods: failedPods))
  427. }
  428. }
  429. }
  430. SpecRepoBuilder.main()