Collecting GitHub Action workflow metrics using Swift

Go from confusion to confidence with a step-by-step Swift Concurrency course, helping you smoothly migrate to Swift 6 and fully leverage its features.
This week at work I have been looking at collecting metrics from GitHub Actions Workflow runs. These metrics will help us monitor and plot the duration of our GitHub Action workflows over time.
To achieve this, I created a command line application which queries GitHub’s API and uses its response to calculate how long each workflow run takes.
The command line application is built using an executable target in a Swift Package with the help of Apple’s swift-argument-parser library. Specifically, the application makes use of the AsyncParsableCommand to run asynchronous code within an async/await context.
Creating a Swift Package
The first step to create a command line tool with Swift is to create a Swift Package:
swift package init --type executable
Amongst other files, the command above generates a Package.swift file with an executable target and product. To use swift-argument-parser, declare it as a package dependency and add it to the executableTarget’s dependency list:
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "GithubWorkflowMetrics",
platforms: [.macOS(.v10_15)],
dependencies: [
.package(
url: "https://github.com/apple/swift-argument-parser.git",
from: "1.2.0"
)
],
targets: [
.executableTarget(
name: "GithubWorkflowMetrics",
dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]
)
]
)
swift-argument-parser only introduced
async/awaitsupport in version 1.1.0. If you want to take advantage of these APIs, you will need to use a version higher or equal to 1.1.0.
Creating the entry point
The executable target needs to know what actions to perform when it runs. The entry point for the application is defined under Sources/GithubWorkflowMetrics/GithubWorkflowMetrics.swift:
import ArgumentParser
import Foundation
@main
struct GithubWorkflowMetrics: AsyncParsableCommand {
@Argument(help: "The repository data should be parsed from in `user/repo` format")
var repository: String
@Option(help: "The bearer token to perform the request")
var token: String?
func run() async throws {
}
}
The code above declares a struct conforming to AsyncParsableCommand decorated with @main to indicate it is the entry point for the executable target. The GithubWorkflowMetrics struct implements the async run method declared in the AsyncParsableCommand protocol from swift-argument-parser and defines two properties:
- A repository to get workflow data from. This argument is required and, if not provided, will cause the application to fail. The
repositoryproperty is decorated with the@Argumentproperty wrapper from swift-argument-parser to ensure it is passed as such. - A token string to authorise the request to GitHub’s api. This property is optional and should only be passed as an option when trying to access private repositories. The property is decorated with the
@Optionproperty wrapper from swift-argument-parser for this reason.
Calling GitHub’s API
GitHub’s API provides an endpoint to retrieve all workflow runs for a repository. The app can make use of URLSession’s await-friendly APIs to call the workflow runs endpoint and decode the response data from the API into a Decodable type:
struct WorkflowsResponse: Decodable {
let workflowRuns: [Workflow]
}
struct Workflow: Decodable {
let name: String
let id: Int
let runStartedAt: Date
let updatedAt: Date
let conclusion: String?
}
extension JSONDecoder {
static let snakeCaseDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}
func run() async throws {
let url = URL(string: "https://api.github.com/repos/\(repository)/actions/runs?status=success&created=\(DateProvider.provide())")!
var urlRequest = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
if let token {
urlRequest
.setValue(
"Bearer \(token)",
forHTTPHeaderField: "Authorization"
)
}
let (data, _) = try await URLSession.shared.data(for: urlRequest)
let decodedData = try JSONDecoder
.snakeCaseDecoder
.decode(WorkflowsResponse.self, from: data)
}
The workflow runs endpoint in GitHub’s API returns JSON data with snake case formatted keys. The
snakeCaseDecoderin the code snippet above usesconvertFromSnakeCaseas itskeyDecodingStrategyto convert the snake case formatted keys from GitHub’s response back into theDecodabletype’s camel case formatted properties. ThesnakeCaseDecoderalso specifiesiso8601as itsdateDecodingStrategyto decode ISO8601 compliant dates from GitHub’s API’s response intoDatetypes.
Generating the output
The application needs to output the metrics collected from GitHub’s API in a suitable format.
I decided to output a JSON object with the following format:
{
"workflow_name": [
{
"date": "workflow_run_date",
"duration": "workflow_run_duration"
},
{
"date": "workflow_run_date",
"duration": "workflow_run_duration"
},
]
}
The Swift code below performs the necessary operations to transform the data coming from GitHub’s API into the output object above:
struct Output: Encodable {
var dataPoints: [String: [WorkflowDataPoint]] = [:]
}
struct WorkflowDataPoint: Encodable {
let duration: TimeInterval
let date: Date
}
extension JSONEncoder {
static let snakeCaseEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = .prettyPrinted
return encoder
}()
}
func run() async throws {
// ...
// 1
decodedData
.workflowRuns
.reduce(into: [String: [WorkflowDataPoint]]()) { partialOutput, workflow in
let duration = workflow
.updatedAt
.timeIntervalSince(workflow.runStartedAt)
let dataPoint = WorkflowDataPoint(
duration: duration,
date: workflow.runStartedAt
)
if var timeIntervals = partialOutput[workflow.name] {
timeIntervals.append(dataPoint)
partialOutput[workflow.name] = timeIntervals
} else {
partialOutput[workflow.name] = [dataPoint]
}
}
// 2
let encodedOutput = try JSONEncoder
.snakeCaseEncoder
.encode(output)
// 3
if let prettyPrintedString = String(data: encodedOutput, encoding: .utf8) {
print(prettyPrintedString)
}
}
Let’s break down the code:
- The decoded data from GitHub’s API’s response is modified and reduced into a more suitable format. The output type is an
Encodablestructwith two properties: the name of the workflow and a set of data points representing each of the runs associated with that workflow. In turn, each of the data points has the duration in seconds and the date when it was run. - The
Outputtype is encoded intoDatausing a customJSONEncoder. This encoder sets an output formatting type of.prettyPrintedto ensure data is easily readable by the application’s users. - The data is converted into a
Stringwhich can be printed to the console.
Let’s test it!
The application takes in a repository name and, if such repository is private, a bearer token with the necessary permissions:
swift run GithubWorkflowMetrics polpielladev/reading-time
reading-time is an Open-Source public repository and, as such, it does not require a bearer token.
The command above yields the following result, which shows all runs for each workflow in the reading-time repository with their date and duration in seconds 🎉:
{
"Windows" : [
{
"date" : "2022-10-02T20:55:24Z",
"duration" : 430
},
{
"date" : "2022-10-02T19:06:44Z",
"duration" : 421
},
{
"date" : "2022-10-02T17:55:37Z",
"duration" : 391
},
{
"date" : "2022-10-02T15:59:51Z",
"duration" : 337
},
{
"date" : "2022-10-02T15:48:38Z",
"duration" : 355
}
],
"CI" : [
{
"date" : "2022-10-02T22:21:52Z",
"duration" : 399
},
{
"date" : "2022-10-02T22:14:18Z",
"duration" : 438
},
{
"date" : "2022-09-29T18:12:34Z",
"duration" : 347
},
{
"date" : "2022-09-29T17:04:53Z",
"duration" : 522
}
]
}