Created
April 22, 2025 13:49
-
-
Save GeroHerkenrath/8f095a6af9fbba6caa168c369e594663 to your computer and use it in GitHub Desktop.
Demo for Swift scripts that import other (.swift) helper files to make code easier to reuse. Works by compiling the helpers to a module first.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env swift -I ScriptHelper/bin/ -L ScriptHelper/bin/ -l ScriptHelper/bin/ScriptHelper | |
/// Example usage of the `ScriptHelper` module. Note that although the file has a `.sh` suffix, | |
/// it is actually Swift code! The name is an Xcode Cloud requirement for the system to invoke it. | |
/// | |
/// This Cloud script simply disables fingerprint checks for Xcode plug-ins and macros. For example, the SwiftLint | |
/// Xcode plugin requires this to properly work on Xcode Cloud. | |
/// | |
/// For it to work, the compiled `ScriptHelper` module should be in a `ScriptHelper/bin/` subfolder, note the | |
/// shebang above! See also the explanatory comments in the `SscriptHelper.swift` and `rebuildBinaries.swift` files! | |
import Foundation | |
import ScriptHelper | |
exitOnError = true | |
let ciXcodeCloud = env["CI_XCODE_CLOUD"] == "TRUE" ? true : false | |
guard ciXcodeCloud else { | |
print("This script should only be run in Xcode Cloud, doing nothing for now.") | |
exit(0) | |
} | |
print("Setting IDESkipPackagePluginFingerprintValidatation and IDESkipMacroFingerprintValidation defaults for", | |
"com.apple.dt.Xcode to YES to auto-trust Xcode plugins and macros (enabling the SwiftLint plugin for now).") | |
eval("defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES") | |
eval("defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES") | |
print("Done setting defaults for Xcode, SwiftLint plugin will now work for this build.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env swift -I ScriptHelper/bin/ -L ScriptHelper/bin/ -l ScriptHelper/bin/ScriptHelper | |
/// Another example usage of the `ScriptHelper` module. Note that although the file has a `.sh` suffix, | |
/// it is actually Swift code! The name is an Xcode Cloud requirement for the system to invoke it. | |
/// | |
/// It reads the overall code coverage of a test-without-bulding action for a single-target project and then compares | |
/// that to a value defined in an environment variable of the workflow. | |
/// This environment variable must be named MINIMUM_CODE_COVERAGE in the workflow and have a float value | |
/// between 0 and 1. If the variable is not set, the script will NOT fail and instead interpret the absence as | |
/// intentional deactivation of the check. | |
/// | |
/// For it to work, the compiled `ScriptHelper` module should be in a `ScriptHelper/bin/` subfolder, note the | |
/// shebang above! See also the explanatory comments in the `SscriptHelper.swift` and `rebuildBinaries.swift` files! | |
import Foundation | |
import ScriptHelper | |
exitOnError = true | |
// MARK: - Getting relevant environment variables | |
let ciXcodeCloud = env["CI_XCODE_CLOUD"] == "TRUE" ? true : false | |
let isTestWithoutBuildingAction = env["CI_XCODEBUILD_ACTION"] == "test-without-building" ? true : false | |
let ciPullRequestSourceCommit = env["CI_PULL_REQUEST_SOURCE_COMMIT"] | |
let ciResultBundlePath = env["CI_RESULT_BUNDLE_PATH"] | |
let minimumCodeCoverageString = env["MINIMUM_CODE_COVERAGE"] | |
// MARK: - Pre-run checks: Are we on CI and in the correct kind of workflow? | |
guard ciXcodeCloud else { | |
print("This script should only be run in Xcode Cloud, skipping check.") | |
exit(0) | |
} | |
guard isTestWithoutBuildingAction else { | |
print("Not running in a testing workflow action, exiting script gracefully.") | |
exit(0) | |
} | |
guard ciPullRequestSourceCommit != nil, let resultBundlePath = ciResultBundlePath, | |
let minCodeCoverage = minimumCodeCoverageString.flatMap({ Double($0) }) else { | |
print("Relevant environment variables to compare code coverage were not found, skipping check.") | |
exit(0) | |
} | |
// MARK: - Ensuring result bundle has code coverage and if so parsing it | |
print("Fetching measured code coverage for single target from result bundle.") | |
let xcresultToolCommand = "xcrun xcresulttool get content-availability --compact --path \(resultBundlePath)" | |
let xccovCommand = "xcrun xccov view --report --only-targets --json \(resultBundlePath)" | |
let decoder = JSONDecoder() | |
guard let xcresulttoolOutputData = evalRaw(xcresultToolCommand), | |
let xcresulttoolOutput = try? decoder.decode(XcResultToolOutput.self, from: xcresulttoolOutputData), | |
xcresulttoolOutput.hasCoverage, | |
let xccovOutputData = evalRaw(xccovCommand), | |
let xccovOutput = try? decoder.decode([XcCovOutput].self, from: xccovOutputData), | |
let codeCoverage = xccovOutput.first?.lineCoverage else { | |
let errString = """ | |
There was no code coverage measured while running tests. Check the testplans and/or scheme, etc. \ | |
or remove the MINIMUM_CODE_COVERAGE environment variable from the workflow to deactive this check. | |
""" | |
print(errString, to: &.stdErr) | |
exit(61) | |
} | |
let ccFormatted = "\(String(format: "%.2f", codeCoverage * 100.0)) % (\(codeCoverage))" | |
let minCcFormatted = "\(String(format: "%.2f", minCodeCoverage * 100.0)) % (\(minCodeCoverage))" | |
print("Overall code coverage is \(ccFormatted)") | |
// MARK: - Compare to the defined minimum | |
guard codeCoverage >= minCodeCoverage else { | |
print("This does NOT satisfy the minimum requirement of \(minCcFormatted)!") | |
print("Code coverage \(ccFormatted) is below threshold of \(minCcFormatted).", to: &.stdErr) | |
exit(1) | |
} | |
print("This is enough to satisfy the minimum requirement of \(minCcFormatted).") | |
// MARK: - Some helper types for parsing | |
struct XcResultToolOutput: Decodable { | |
// for now we are only interested in this | |
let hasCoverage: Bool | |
} | |
struct XcCovOutput: Decodable { | |
// for now we are only interested in this | |
let lineCoverage: Double | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env swift | |
/// This Swift script is used to rebuild the ScriptHelper module in-place, i.e. it will delete the bin folder, | |
/// recompile the `ScriptHelper.swift`, and package it into a usable universal binary for `arm64` and `x86_64` | |
/// machines (which can be especially important on CI systems where runners may have one architecture or the other). | |
/// If anything was changed in the `ScriptHelper.swift` file you **must** execute this shell script before you can use | |
/// any changes in the "regular" scripts that use the `ScriptHelper` module. | |
/// The script should reside in the same folder that contains the `ScriptHelper.swift` file. On Xcode Cloud, this should | |
// then in turn be kept in the `ci_scripts` folder (and can e.g. be named just `ScriptHelper`). | |
import Foundation | |
let flags = "-emit-library -emit-module -parse-as-library -swift-version 6 -O" | |
let armArch = "arm64-apple-macosx15.0" | |
let x86Arch = "x86_64-apple-macosx15.0" | |
let modFolder = "bin/ScriptHelper.swiftmodule" | |
print("Resetting bin folder") | |
exec("rm -rf bin") | |
print("Creating modules for needed architectures") | |
exec("swiftc -target \(armArch) \(flags) ScriptHelper.swift -o bin/build/arm64/ScriptHelper") | |
exec("swiftc -target \(x86Arch) \(flags) ScriptHelper.swift -o bin/build/x86_64/ScriptHelper") | |
print("Merging binary into a universal one") | |
exec("lipo -create bin/build/arm64/ScriptHelper bin/build/x86_64/ScriptHelper -output bin/ScriptHelper") | |
print("Moving metadata files to correct locations") | |
exec("mkdir -p \(modFolder)") | |
for oneSuffix in [".swiftdoc", ".swiftmodule"] { | |
exec("mv bin/build/arm64/ScriptHelper\(oneSuffix) \(modFolder)/arm64-apple-macos\(oneSuffix)") | |
exec("mv bin/build/x86_64/ScriptHelper\(oneSuffix) \(modFolder)/x86_64-apple-macos\(oneSuffix)") | |
} | |
print("Cleaning up") | |
exec("rm -rf bin/build") | |
print("ScriptHelper binaries have been rebuild, you can use any changes in script now!") | |
func exec(_ commandString: String) { | |
let process = Process() | |
process.currentDirectoryURL = URL(filePath: #filePath).absoluteURL.deletingLastPathComponent() | |
process.launchPath = "/usr/bin/env" | |
process.arguments = commandString.components(separatedBy: " ") | |
try? process.run() | |
process.waitUntilExit() | |
if process.terminationStatus != 0 { | |
exit(process.terminationStatus) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// This file contains some helper properies, functions, and types that make writing other scripts a lot easier. | |
/// Put this and the related `rebuildBinaries.swift` file into a folder named "ScriptHelper" on the same level as | |
/// the scripts or adapt the paths below accordingly. | |
/// | |
/// To then use the helper in a Swift script you need to do three things: | |
/// 1. Compile it into an importable module | |
/// 2. Make it visible and linkable to your script and import it in its code | |
/// | |
/// For the first point there is a helper script provided, see `rebuildBinaries.swift`. It's also a Swift script, | |
/// but does not depend on these helpers. Used as is it compiles the module into a `bin` folder and simply names | |
/// the module `ScriptHelper`. | |
/// The second point then simply means to update a scrip's shebang to make the module available, like this: | |
/// `#!/usr/bin/env swift -I ScriptHelper/bin/ -L ScriptHelper/bin/ -l ScriptHelper/bin/ScriptHelper`. | |
/// Then the helper can be imported via `import ScriptHelper` in the script's code. | |
/// | |
/// | |
/// **ANY CHANGES IN THIS FILE REQUIRE A REBUILD OF THE MODULE BEFORE THEY CAN BE USED IN A SCRIPT!** | |
/// | |
/// This is especially important if you want to use the helper in a script running on a CI system (like Xcode Cloud) | |
/// and do not want to recompile it _on the CI runner itself_. This is also the reason why the module that the | |
/// `rebuildBinaries.swift` creates a universal binary (i.e. the resulting module supports both, arm64 and x86_64). | |
/// While you then have to check in the resulting binary into your repository, this avoids having to have yet another | |
/// script that runs first and compiles the module. On e.g. Xcode Cloud that allows you to keep things relatively | |
/// small as the system only invokes specifically named scripts. | |
/// Since the helper code below is intended to rarely change and can be viewed as a "static local dependency" | |
/// that should be fine and manageable. | |
import Foundation | |
// MARK: - Global variables to set general script behavior | |
/// Set whether any errors during calling external commands with `eval(_:atPath:)` or `evalRaw(_:atPath:) should | |
/// halt the script. Defaults to `true`. | |
/// | |
/// If this variable is `true` and an external command invoked during the `eval(_:atPath:)` or `evalRaw(_:atPath:)` | |
/// methods ends with a non-zero termination status, the script invokes `exit()` with that termination status, | |
/// effectively ending script execution. | |
nonisolated(unsafe) public var exitOnError = false | |
/// A convenience variable to quickly access environment variables. | |
/// | |
/// For example, to get the value of the environment variable `CI_XCODE_CLOUD`, pass its name | |
/// to the subscript of the object via `env["CI_XCODE_CLOUD"]`. | |
nonisolated(unsafe) public let env = EnvironmentVariables() | |
// MARK: - Functions to ease script writing | |
/// Invoke native commands in your environment returning any `String` they may return. | |
/// | |
/// This function allows you to execute the typical system tools you need in shells. Internally it creates a | |
/// `Process` object for this purpose. If the invoked command outputs something to standard output, the function | |
/// returns that as a `String`. Standard error is not monitored like this, though. If the global `exitOnError` | |
/// property is set to `true`, the function calls `exit()` if the invoked command does not return 0. | |
@discardableResult | |
public func eval(_ command: String, atPath filePath: String? = nil) -> String? { | |
return evalRaw(command, atPath: filePath).flatMap { String(data: $0, encoding: .utf8) } | |
} | |
/// Invoke native commands in your environment returning the raw `Data` they may return. | |
/// | |
/// This function allows you to execute the typical system tools you need in shells. Internally it creates a | |
/// `Process` object for this purpose. If the invoked command outputs something to standard output, the function | |
/// returns that as `Data`. Standard error is not monitored like this, though. If the global `exitOnError` | |
/// property is set to `true`, the function calls `exit()` if the invoked command does not return 0. | |
@discardableResult | |
public func evalRaw(_ command: String, atPath filePath: String? = nil) -> Data? { | |
let split = command.components(separatedBy: " ") | |
guard split.count > 0 else { | |
return nil | |
} | |
let process = Process() | |
process.executableURL = URL(filePath: "/usr/bin/env") | |
process.arguments = split | |
process.currentDirectoryURL = URL(filePath: filePath ?? FileManager.default.currentDirectoryPath) | |
let pipe = Pipe() | |
process.standardOutput = pipe | |
try? process.run() | |
process.waitUntilExit() | |
if process.terminationStatus != 0, exitOnError { | |
exit(process.terminationStatus) | |
} | |
return try? pipe.fileHandleForReading.readToEnd() | |
} | |
// MARK: - Some helper types to facilitate all above | |
/// A helper type to access the script's environment variables. | |
public struct EnvironmentVariables { | |
/// Returns the value of the given environment variable or `nil` of it is not defined. | |
public subscript(_ key: String) -> String? { | |
get { ProcessInfo.processInfo.environment[key] } | |
} | |
} | |
/// A helper type that allows to `print` easily to standard error. | |
/// | |
/// See the extension that provides the `stdErr` static property to `TextOutputStream`. | |
public struct StdErrorWriter: TextOutputStream { | |
nonisolated(unsafe) fileprivate static var stdErr = StdErrorWriter() | |
public func write(_ string: String) { | |
FileHandle.standardError.write(string.data(using: .utf8)!) | |
} | |
} | |
/// Extending `TextOutputStream` with a convenience static property to allow easy printing to standard error. | |
/// | |
/// This enables calling `print(_:separator:terminator:to:)` like e.g. this: `print("some error", to: &.stdErr)`. | |
public extension TextOutputStream where Self == StdErrorWriter { | |
/// A convenience static property to allow easy printing to standard error. | |
/// | |
/// This enables calling `print(_:separator:terminator:to:)` like e.g. this: `print("some error", to: &.stdErr)`. | |
static var stdErr: StdErrorWriter { | |
get { StdErrorWriter.stdErr } | |
set { StdErrorWriter.stdErr = newValue } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment