From 4e6518ec54976a2f67175bf45bf8369ea8a81b8d Mon Sep 17 00:00:00 2001 From: Daymon Date: Tue, 14 Oct 2025 13:03:33 -0500 Subject: [PATCH 01/11] Add repo package --- scripts/repo/Package.swift | 50 ++++++ scripts/repo/Sources/Tests/Decrypt.swift | 175 ++++++++++++++++++++ scripts/repo/Sources/Tests/SecretFile.swift | 72 ++++++++ scripts/repo/Sources/Tests/main.swift | 54 ++++++ scripts/repo/Sources/Util/Process.swift | 122 ++++++++++++++ 5 files changed, 473 insertions(+) create mode 100755 scripts/repo/Package.swift create mode 100755 scripts/repo/Sources/Tests/Decrypt.swift create mode 100755 scripts/repo/Sources/Tests/SecretFile.swift create mode 100755 scripts/repo/Sources/Tests/main.swift create mode 100755 scripts/repo/Sources/Util/Process.swift diff --git a/scripts/repo/Package.swift b/scripts/repo/Package.swift new file mode 100755 index 00000000000..b75d4af4e01 --- /dev/null +++ b/scripts/repo/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version:6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +/* + * Copyright 2025 Google + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import PackageDescription + +/// Package containing CLI executables for our larger scripts that are a bit harder to follow in bash form, or +/// that need more advanced flag/optional requirements. +let package = Package( + name: "RepoScripts", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "tests", targets: ["Tests"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.6.2"), + .package(url: "https://github.com/apple/swift-log", exact: "1.6.2"), + ], + targets: [ + .executableTarget( + name: "Tests", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .byName(name: "Util"), + ] + ), + .target( + name: "Util", + dependencies: [ + .product(name: "Logging", package: "swift-log") + ] + ), + ] +) diff --git a/scripts/repo/Sources/Tests/Decrypt.swift b/scripts/repo/Sources/Tests/Decrypt.swift new file mode 100755 index 00000000000..ded53125c17 --- /dev/null +++ b/scripts/repo/Sources/Tests/Decrypt.swift @@ -0,0 +1,175 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ArgumentParser +import Foundation +import Logging + +extension Tests { + /// Command for decrypting the secret files needed for a test run. + struct Decrypt: ParsableCommand { + nonisolated(unsafe) static var configuration = CommandConfiguration( + abstract: "Decrypt the secret files for a test run.", + usage: """ + tests decrypt [--json] [--overwrite] [] + tests decrypt [--password ] [--overwrite] [ ...] + + tests decrypt --json secret_files.json + tests decrypt --json --overwrite secret_files.json + tests decrypt --password "super_secret" \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist + """, + discussion: """ + The happy path usage is saving the secret passphrase in the environment variable \ + 'secrets_passphrase', and passing a json file to the command. Although, you can also \ + pass everything inline via options. + + When using a json file, it's expected that the json file is an array of json elements \ + in the format of: + { encrypted: , destination: } + """, + ) + + @Argument( + help: """ + An array of secret files to decrypt. \ + The files should be in the format "encrypted:destination", where "encrypted" is a path to \ + the encrypted file and "destination" is a path to where the decrypted file should be saved. + """ + ) + var secretFiles: [String] = [] + + @Option( + help: """ + The secret to use when decrypting the files. \ + Defaults to the environment variable 'secrets_passphrase'. + """ + ) + var password: String = "" + + @Flag(help: "Overwrite existing decrypted secret files.") + var overwrite: Bool = false + + @Flag( + help: """ + Use a json file of secret file mappings instead. \ + When this flag is enabled, should be a single json file. + """ + ) + var json: Bool = false + + /// The parsed version of ``secretFiles``. + /// + /// Only populated after `validate()` runs. + var files: [SecretFile] = [] + + static let log: Logger = Logger(label: "Tests::Decrypt") + private var log: Logger { Decrypt.log } + + mutating func validate() throws { + try validatePassword() + + if json { + try validateJSON() + } else { + try validateFileString() + } + + if !overwrite { + // when overwrite is disabled, we don't want to update files that already exist + files = files.filter { file in + !FileManager.default.fileExists(atPath: file.destination) + } + } + + for file in files { + guard FileManager.default.fileExists(atPath: file.encrypted) else { + throw ValidationError("Encrypted secret file does not exist: \(file.encrypted)") + } + } + } + + private mutating func validatePassword() throws { + if password.isEmpty { + // when a password isn't provided, try to load one from the environment variable + guard + let secrets_passphrase = ProcessInfo.processInfo.environment["secrets_passphrase"] + else { + throw ValidationError( + "Either provide a passphrase via the password option or set the environvment variable 'secrets_passphrase' to the passphrase." + ) + } + password = secrets_passphrase + } + } + + private mutating func validateJSON() throws { + guard let jsonPath = secretFiles.first else { + throw ValidationError("Missing path to json file for secret files") + } + + let fileURL = URL( + filePath: jsonPath, directoryHint: .notDirectory, + relativeTo: URL.currentDirectory() + ) + + files = try SecretFile.parseArrayFrom(file: fileURL) + guard !files.isEmpty else { + throw ValidationError("Missing secret files in json file: \(jsonPath)") + } + } + + private mutating func validateFileString() throws { + guard !secretFiles.isEmpty else { + throw ValidationError("Missing paths to secret files") + } + for string in secretFiles { + files.append(try SecretFile(string: string)) + } + } + + mutating func run() throws { + log.info("Decrypting files...") + + for file in files { + let gpg = Process("gpg", inheritEnvironment: true) + let result = try gpg.runWithSignals([ + "--quiet", + "--batch", + "--yes", + "--decrypt", + "--passphrase=\(password)", + "--output", + file.destination, + file.encrypted, + ]) + + guard result == 0 else { + log.error("Failed to decrypt file", metadata: ["file": "\(file.encrypted)"]) + throw ExitCode(result) + } + + log.debug( + "File encrypted", + metadata: ["file": "\(file.encrypted)", "destination": "\(file.destination)"] + ) + } + + log.info("Files decrypted") + } + } +} diff --git a/scripts/repo/Sources/Tests/SecretFile.swift b/scripts/repo/Sources/Tests/SecretFile.swift new file mode 100755 index 00000000000..6b34249c11a --- /dev/null +++ b/scripts/repo/Sources/Tests/SecretFile.swift @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ArgumentParser +import Foundation + +/// A representation of a secret file, which should be decrypted for an integration test. +struct SecretFile: Codable { + /// A relative path to the encrypted file. + let encrypted: String + + /// A relative path to where the decrypted file should be output to. + let destination: String +} + +extension SecretFile { + /// Parses a `SecretFile` from a string. + /// + /// The string should be in the format of "encrypted:destination". + /// If it's not, then a `ValidationError`will be thrown. + /// + /// - Parameters: + /// - string: A string in the format of "encrypted:destination". + init(string: String) throws { + let splits = string.split(separator: ":") + guard splits.count == 2 else { + throw ValidationError( + "Invalid secret file format. Format should be \"encrypted:destination\". Cause: \(string)" + ) + } + self.encrypted = String(splits[0]) + self.destination = String(splits[1]) + } + + /// Parses an array of `SecretFile` from a JSON file. + /// + /// It's expected that the secrets are encoded in the JSON file in the format of: + /// ```json + /// [ + /// { + /// "encrypted": "path-to-encrypted-file", + /// "destination": "where-to-output-decrypted-file" + /// } + /// ] + /// ``` + /// + /// - Parameters: + /// - file: The URL of a JSON file which contains an array of `SecretFile`, + /// encoded as JSON. + static func parseArrayFrom(file: URL) throws -> [SecretFile] { + do { + let data = try Data(contentsOf: file) + return try JSONDecoder().decode([SecretFile].self, from: data) + } catch { + throw ValidationError( + "Failed to load secret files from json file. Cause: \(error.localizedDescription)") + } + } +} diff --git a/scripts/repo/Sources/Tests/main.swift b/scripts/repo/Sources/Tests/main.swift new file mode 100755 index 00000000000..6d387556abd --- /dev/null +++ b/scripts/repo/Sources/Tests/main.swift @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ArgumentParser +import Logging +import Foundation + +struct Tests: ParsableCommand { + nonisolated(unsafe) static var configuration = CommandConfiguration( + abstract: "Commands for running and interacting with integration tests.", + discussion: """ + A note on logging: by default, only log levels "info" and above are logged. For further \ + debugging, you can set the "LOG_LEVEL" environment variable to a different minimum level \ + (eg; "debug"). + """, + subcommands: [Decrypt.self] + // defaultSubcommand: Run.self + ) +} + +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + if let level = ProcessInfo.processInfo.environment["LOG_LEVEL"] { + if let parsedLevel = Logger.Level(rawValue: String(level)) { + handler.logLevel = parsedLevel + return handler + } else { + print( + """ + [WARNING]: Unrecognized log level "\(level)"; defaulting to "info". + Valid values: \(Logger.Level.allCases.map(\.rawValue)) + """ + ) + } + } + + handler.logLevel = .info + return handler +} + +Tests.main() diff --git a/scripts/repo/Sources/Util/Process.swift b/scripts/repo/Sources/Util/Process.swift new file mode 100755 index 00000000000..8b9c20d9626 --- /dev/null +++ b/scripts/repo/Sources/Util/Process.swift @@ -0,0 +1,122 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Dispatch +import Foundation + +extension Process { + /// Creates a new `Process` instance without running it. + /// + /// - Parameters: + /// - exe: The executable to run. + /// - args: An array of arguments to pass to the executable. + /// - env: A map of environment variables to set for the process. + /// - inheritEnvironment: When enabled, the parent process' environvment will also be applied + /// to this process. Effectively, this means that any environvment variables declared within the parent + /// process will propogate down to this new process. + public convenience init( + _ exe: String, + _ args: [String] = [], + env: [String: String] = [:], + inheritEnvironment: Bool = false + ) { + self.init() + self.executableURL = URL(filePath: exe) + self.arguments = args + self.environment = env + if inheritEnvironment { + mergeEnvironment(ProcessInfo.processInfo.environment) + } + } + + /// Merges the provided environment variables with this process' existing environment variables. + /// + /// If an environment variable is already set, then it will **NOT** be overwritten. Only environment + /// variables not currently set on the process will be applied. + /// + /// - Parameters: + /// - env: The environment variables to merge with this process. + public func mergeEnvironment(_ env: [String: String]) { + guard environment != nil else { + // if this process doesn't have an environment, we can just set it instead of merging + environment = env + return + } + + environment = environment?.merging(env) { (current, _) in current } + } + + /// Run the process with signals from the parent process. + /// + /// The signals `SIGINT` and `SIGTERM` will both be propogated + /// down to the process from the parent process. + /// + /// This function will not return until the process is done running. + /// + /// - Parameters: + /// - args: Optionally provide an array of arguments to run the process with. + /// + /// - Returns: The exit code that the process completed with. + @discardableResult + public func runWithSignals(_ args: [String]? = nil) throws -> Int32 { + if let args { + self.arguments = args + } + + let sigint = bindSignal(signal: SIGINT) { + if self.isRunning { + self.interrupt() + } + } + + let sigterm = bindSignal(signal: SIGTERM) { + if self.isRunning { + self.terminate() + } + } + + sigint.resume() + sigterm.resume() + + try run() + waitUntilExit() + + return terminationStatus + } +} + +/// Binds a callback to a signal from the parent process. +/// +/// ```swift +/// bindSignal(SIGINT) { +/// print("SIGINT was triggered") +/// } +/// ``` +/// +/// - Parameters: +/// - signal: The signal to listen for. +/// - callback: The function to invoke when the signal is received. +func bindSignal( + signal value: Int32, callback: @escaping DispatchSourceProtocol.DispatchSourceHandler +) -> any DispatchSourceSignal { + // allow the process to survive long enough to trigger the callback + signal(value, SIG_IGN) + + let dispatch = DispatchSource.makeSignalSource(signal: value, queue: .main) + dispatch.setEventHandler(handler: callback) + + return dispatch +} From 0576ea9d9012fff80377d17452c2910126785207 Mon Sep 17 00:00:00 2001 From: Daymon Date: Tue, 14 Oct 2025 13:07:09 -0500 Subject: [PATCH 02/11] Add AI secret json file --- scripts/secrets/AI.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100755 scripts/secrets/AI.json diff --git a/scripts/secrets/AI.json b/scripts/secrets/AI.json new file mode 100755 index 00000000000..1f675c8fc3e --- /dev/null +++ b/scripts/secrets/AI.json @@ -0,0 +1,14 @@ +[ + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg", + "destination": "FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist" + }, + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg", + "destination": "FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist" + }, + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg", + "destination": "FirebaseAI/Tests/TestApp/Tests/Integration/Credentials.swift" + } +] From f1bc405159f2df0896e4a941ebb395bd3f8dc5e7 Mon Sep 17 00:00:00 2001 From: Daymon Date: Tue, 14 Oct 2025 13:07:16 -0500 Subject: [PATCH 03/11] Add helper script --- scripts/repo.sh | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100755 scripts/repo.sh diff --git a/scripts/repo.sh b/scripts/repo.sh new file mode 100755 index 00000000000..607ad987a1a --- /dev/null +++ b/scripts/repo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# USAGE: ./repo.sh [args...] +# +# EXAMPLE: ./repo.sh tests decrypt --json ./scripts/secrets/AI.json +# +# Wraps around the local "repo" swift package, and facilitates calls to it. +# The main purpose of this is to make calling "repo" easier, as you typically +# need to call "swift run" with the package path. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -eq 0 ]]; then + cat 1>&2 < [args...] +EOF + exit 1 +fi + +swift run --package-path "${ROOT}/repo" "$@" From 5a091a733a90ab16161123e3bd41d8bf5ca46f00 Mon Sep 17 00:00:00 2001 From: Daymon Date: Tue, 14 Oct 2025 13:07:21 -0500 Subject: [PATCH 04/11] Add readme --- scripts/repo/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 scripts/repo/README.md diff --git a/scripts/repo/README.md b/scripts/repo/README.md new file mode 100644 index 00000000000..2d45f3d8852 --- /dev/null +++ b/scripts/repo/README.md @@ -0,0 +1,13 @@ +# Firebase iOS repo commands + +This project includes commands that are too long and complicated to properly +maintain in a bash script, or that have unique option/flag constraints that +are better represented in a swift project. + +## Tests + +Commands for interacting with integration tests in the repo. + +```sh +./scripts/repo.sh tests --help +``` From adad793da5748c7fb91841f30bfd46061e182357 Mon Sep 17 00:00:00 2001 From: Daymon Date: Tue, 14 Oct 2025 13:13:32 -0500 Subject: [PATCH 05/11] Update Package.swift --- scripts/repo/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repo/Package.swift b/scripts/repo/Package.swift index b75d4af4e01..011b944698f 100755 --- a/scripts/repo/Package.swift +++ b/scripts/repo/Package.swift @@ -2,7 +2,7 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. /* - * Copyright 2025 Google + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From f01d6622f66c2377d5101ceee785c375cef624c1 Mon Sep 17 00:00:00 2001 From: Daymon Date: Tue, 14 Oct 2025 13:55:31 -0500 Subject: [PATCH 06/11] Formatting --- scripts/repo/Package.swift | 8 +-- scripts/repo/Sources/Tests/Decrypt.swift | 58 ++++++++++----------- scripts/repo/Sources/Tests/SecretFile.swift | 7 +-- scripts/repo/Sources/Tests/main.swift | 10 ++-- scripts/repo/Sources/Util/Process.swift | 40 +++++++------- 5 files changed, 61 insertions(+), 62 deletions(-) diff --git a/scripts/repo/Package.swift b/scripts/repo/Package.swift index 011b944698f..1edeeb69aad 100755 --- a/scripts/repo/Package.swift +++ b/scripts/repo/Package.swift @@ -19,13 +19,13 @@ import PackageDescription -/// Package containing CLI executables for our larger scripts that are a bit harder to follow in bash form, or -/// that need more advanced flag/optional requirements. +/// Package containing CLI executables for our larger scripts that are a bit harder to follow in +/// bash form, or that need more advanced flag/optional requirements. let package = Package( name: "RepoScripts", platforms: [.macOS(.v15)], products: [ - .executable(name: "tests", targets: ["Tests"]) + .executable(name: "tests", targets: ["Tests"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.6.2"), @@ -43,7 +43,7 @@ let package = Package( .target( name: "Util", dependencies: [ - .product(name: "Logging", package: "swift-log") + .product(name: "Logging", package: "swift-log"), ] ), ] diff --git a/scripts/repo/Sources/Tests/Decrypt.swift b/scripts/repo/Sources/Tests/Decrypt.swift index ded53125c17..9f48e030b20 100755 --- a/scripts/repo/Sources/Tests/Decrypt.swift +++ b/scripts/repo/Sources/Tests/Decrypt.swift @@ -24,40 +24,40 @@ extension Tests { nonisolated(unsafe) static var configuration = CommandConfiguration( abstract: "Decrypt the secret files for a test run.", usage: """ - tests decrypt [--json] [--overwrite] [] - tests decrypt [--password ] [--overwrite] [ ...] - - tests decrypt --json secret_files.json - tests decrypt --json --overwrite secret_files.json - tests decrypt --password "super_secret" \\ - scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist \\ - scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist - """, + tests decrypt [--json] [--overwrite] [] + tests decrypt [--password ] [--overwrite] [ ...] + + tests decrypt --json secret_files.json + tests decrypt --json --overwrite secret_files.json + tests decrypt --password "super_secret" \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist + """, discussion: """ - The happy path usage is saving the secret passphrase in the environment variable \ - 'secrets_passphrase', and passing a json file to the command. Although, you can also \ - pass everything inline via options. - - When using a json file, it's expected that the json file is an array of json elements \ - in the format of: - { encrypted: , destination: } - """, + The happy path usage is saving the secret passphrase in the environment variable \ + 'secrets_passphrase', and passing a json file to the command. Although, you can also \ + pass everything inline via options. + + When using a json file, it's expected that the json file is an array of json elements \ + in the format of: + { encrypted: , destination: } + """, ) @Argument( help: """ - An array of secret files to decrypt. \ - The files should be in the format "encrypted:destination", where "encrypted" is a path to \ - the encrypted file and "destination" is a path to where the decrypted file should be saved. - """ + An array of secret files to decrypt. \ + The files should be in the format "encrypted:destination", where "encrypted" is a path to \ + the encrypted file and "destination" is a path to where the decrypted file should be saved. + """ ) var secretFiles: [String] = [] @Option( help: """ - The secret to use when decrypting the files. \ - Defaults to the environment variable 'secrets_passphrase'. - """ + The secret to use when decrypting the files. \ + Defaults to the environment variable 'secrets_passphrase'. + """ ) var password: String = "" @@ -66,9 +66,9 @@ extension Tests { @Flag( help: """ - Use a json file of secret file mappings instead. \ - When this flag is enabled, should be a single json file. - """ + Use a json file of secret file mappings instead. \ + When this flag is enabled, should be a single json file. + """ ) var json: Bool = false @@ -77,7 +77,7 @@ extension Tests { /// Only populated after `validate()` runs. var files: [SecretFile] = [] - static let log: Logger = Logger(label: "Tests::Decrypt") + static let log = Logger(label: "Tests::Decrypt") private var log: Logger { Decrypt.log } mutating func validate() throws { @@ -138,7 +138,7 @@ extension Tests { throw ValidationError("Missing paths to secret files") } for string in secretFiles { - files.append(try SecretFile(string: string)) + try files.append(SecretFile(string: string)) } } diff --git a/scripts/repo/Sources/Tests/SecretFile.swift b/scripts/repo/Sources/Tests/SecretFile.swift index 6b34249c11a..67d5e953981 100755 --- a/scripts/repo/Sources/Tests/SecretFile.swift +++ b/scripts/repo/Sources/Tests/SecretFile.swift @@ -41,8 +41,8 @@ extension SecretFile { "Invalid secret file format. Format should be \"encrypted:destination\". Cause: \(string)" ) } - self.encrypted = String(splits[0]) - self.destination = String(splits[1]) + encrypted = String(splits[0]) + destination = String(splits[1]) } /// Parses an array of `SecretFile` from a JSON file. @@ -66,7 +66,8 @@ extension SecretFile { return try JSONDecoder().decode([SecretFile].self, from: data) } catch { throw ValidationError( - "Failed to load secret files from json file. Cause: \(error.localizedDescription)") + "Failed to load secret files from json file. Cause: \(error.localizedDescription)" + ) } } } diff --git a/scripts/repo/Sources/Tests/main.swift b/scripts/repo/Sources/Tests/main.swift index 6d387556abd..0abb218e46a 100755 --- a/scripts/repo/Sources/Tests/main.swift +++ b/scripts/repo/Sources/Tests/main.swift @@ -15,17 +15,17 @@ */ import ArgumentParser -import Logging import Foundation +import Logging struct Tests: ParsableCommand { nonisolated(unsafe) static var configuration = CommandConfiguration( abstract: "Commands for running and interacting with integration tests.", discussion: """ - A note on logging: by default, only log levels "info" and above are logged. For further \ - debugging, you can set the "LOG_LEVEL" environment variable to a different minimum level \ - (eg; "debug"). - """, + A note on logging: by default, only log levels "info" and above are logged. For further \ + debugging, you can set the "LOG_LEVEL" environment variable to a different minimum level \ + (eg; "debug"). + """, subcommands: [Decrypt.self] // defaultSubcommand: Run.self ) diff --git a/scripts/repo/Sources/Util/Process.swift b/scripts/repo/Sources/Util/Process.swift index 8b9c20d9626..d915e93c1af 100755 --- a/scripts/repo/Sources/Util/Process.swift +++ b/scripts/repo/Sources/Util/Process.swift @@ -17,7 +17,7 @@ import Dispatch import Foundation -extension Process { +public extension Process { /// Creates a new `Process` instance without running it. /// /// - Parameters: @@ -25,18 +25,16 @@ extension Process { /// - args: An array of arguments to pass to the executable. /// - env: A map of environment variables to set for the process. /// - inheritEnvironment: When enabled, the parent process' environvment will also be applied - /// to this process. Effectively, this means that any environvment variables declared within the parent - /// process will propogate down to this new process. - public convenience init( - _ exe: String, - _ args: [String] = [], - env: [String: String] = [:], - inheritEnvironment: Bool = false - ) { + /// to this process. Effectively, this means that any environvment variables declared within the + /// parent process will propogate down to this new process. + convenience init(_ exe: String, + _ args: [String] = [], + env: [String: String] = [:], + inheritEnvironment: Bool = false) { self.init() - self.executableURL = URL(filePath: exe) - self.arguments = args - self.environment = env + executableURL = URL(filePath: exe) + arguments = args + environment = env if inheritEnvironment { mergeEnvironment(ProcessInfo.processInfo.environment) } @@ -44,19 +42,19 @@ extension Process { /// Merges the provided environment variables with this process' existing environment variables. /// - /// If an environment variable is already set, then it will **NOT** be overwritten. Only environment - /// variables not currently set on the process will be applied. + /// If an environment variable is already set, then it will **NOT** be overwritten. Only + /// environment variables not currently set on the process will be applied. /// /// - Parameters: /// - env: The environment variables to merge with this process. - public func mergeEnvironment(_ env: [String: String]) { + func mergeEnvironment(_ env: [String: String]) { guard environment != nil else { // if this process doesn't have an environment, we can just set it instead of merging environment = env return } - environment = environment?.merging(env) { (current, _) in current } + environment = environment?.merging(env) { current, _ in current } } /// Run the process with signals from the parent process. @@ -71,9 +69,9 @@ extension Process { /// /// - Returns: The exit code that the process completed with. @discardableResult - public func runWithSignals(_ args: [String]? = nil) throws -> Int32 { + func runWithSignals(_ args: [String]? = nil) throws -> Int32 { if let args { - self.arguments = args + arguments = args } let sigint = bindSignal(signal: SIGINT) { @@ -109,9 +107,9 @@ extension Process { /// - Parameters: /// - signal: The signal to listen for. /// - callback: The function to invoke when the signal is received. -func bindSignal( - signal value: Int32, callback: @escaping DispatchSourceProtocol.DispatchSourceHandler -) -> any DispatchSourceSignal { +func bindSignal(signal value: Int32, + callback: @escaping DispatchSourceProtocol + .DispatchSourceHandler) -> any DispatchSourceSignal { // allow the process to survive long enough to trigger the callback signal(value, SIG_IGN) From c328bb2a439342552a8916c5548f3c702a186334 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:17:12 -0500 Subject: [PATCH 07/11] Update scripts/repo/README.md Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- scripts/repo/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repo/README.md b/scripts/repo/README.md index 2d45f3d8852..318c16c547d 100644 --- a/scripts/repo/README.md +++ b/scripts/repo/README.md @@ -1,4 +1,4 @@ -# Firebase iOS repo commands +# Firebase Apple repo commands This project includes commands that are too long and complicated to properly maintain in a bash script, or that have unique option/flag constraints that From 8c566c912c87723aa9a15d325f9ff39ef36ca419 Mon Sep 17 00:00:00 2001 From: Daymon Date: Thu, 16 Oct 2025 14:25:58 -0500 Subject: [PATCH 08/11] Add log for skipping existing files --- scripts/repo/Sources/Tests/Decrypt.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/repo/Sources/Tests/Decrypt.swift b/scripts/repo/Sources/Tests/Decrypt.swift index 9f48e030b20..e389e865df4 100755 --- a/scripts/repo/Sources/Tests/Decrypt.swift +++ b/scripts/repo/Sources/Tests/Decrypt.swift @@ -90,9 +90,16 @@ extension Tests { } if !overwrite { - // when overwrite is disabled, we don't want to update files that already exist + log.info("Overwrite is disabled, so we're skipping generation for existing files.") files = files.filter { file in - !FileManager.default.fileExists(atPath: file.destination) + let exists = FileManager.default.fileExists(atPath: file.destination) + if !exists { + log.debug( + "Skipping generation for existing file", + metadata: ["destination": "\(file.destination)"] + ) + } + return exists } } From b36486ebd095f2187e64b978d43168b355061fef Mon Sep 17 00:00:00 2001 From: Daymon Date: Thu, 16 Oct 2025 16:24:23 -0500 Subject: [PATCH 09/11] Add missing import --- scripts/repo/Sources/Tests/Decrypt.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/repo/Sources/Tests/Decrypt.swift b/scripts/repo/Sources/Tests/Decrypt.swift index e389e865df4..c244d15b6de 100755 --- a/scripts/repo/Sources/Tests/Decrypt.swift +++ b/scripts/repo/Sources/Tests/Decrypt.swift @@ -17,6 +17,7 @@ import ArgumentParser import Foundation import Logging +import Util extension Tests { /// Command for decrypting the secret files needed for a test run. From 22774931af919e9526c49023f25926c4f946b63f Mon Sep 17 00:00:00 2001 From: Daymon Date: Thu, 16 Oct 2025 16:25:49 -0500 Subject: [PATCH 10/11] Fix bug in filter --- scripts/repo/Sources/Tests/Decrypt.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/repo/Sources/Tests/Decrypt.swift b/scripts/repo/Sources/Tests/Decrypt.swift index c244d15b6de..b1836787be9 100755 --- a/scripts/repo/Sources/Tests/Decrypt.swift +++ b/scripts/repo/Sources/Tests/Decrypt.swift @@ -93,14 +93,14 @@ extension Tests { if !overwrite { log.info("Overwrite is disabled, so we're skipping generation for existing files.") files = files.filter { file in - let exists = FileManager.default.fileExists(atPath: file.destination) - if !exists { + let keep = !FileManager.default.fileExists(atPath: file.destination) + if !keep { log.debug( "Skipping generation for existing file", metadata: ["destination": "\(file.destination)"] ) } - return exists + return keep } } From bfc293ef3d8f0217166f91697c5b963f313cc240 Mon Sep 17 00:00:00 2001 From: Daymon Date: Thu, 16 Oct 2025 16:36:25 -0500 Subject: [PATCH 11/11] Fix bug with file path --- scripts/repo/Sources/Util/Process.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/repo/Sources/Util/Process.swift b/scripts/repo/Sources/Util/Process.swift index d915e93c1af..2959d5f0abb 100755 --- a/scripts/repo/Sources/Util/Process.swift +++ b/scripts/repo/Sources/Util/Process.swift @@ -32,8 +32,8 @@ public extension Process { env: [String: String] = [:], inheritEnvironment: Bool = false) { self.init() - executableURL = URL(filePath: exe) - arguments = args + executableURL = URL(filePath: "/usr/bin/env") + arguments = [exe] + args environment = env if inheritEnvironment { mergeEnvironment(ProcessInfo.processInfo.environment) @@ -71,7 +71,7 @@ public extension Process { @discardableResult func runWithSignals(_ args: [String]? = nil) throws -> Int32 { if let args { - arguments = args + arguments = (arguments ?? []) + args } let sigint = bindSignal(signal: SIGINT) {