Turning CI Logs into Actions
07 Sep 2025When a CI job fails, the first thing you usually do is scroll through a wall of logs trying to spot the error. It’s tedious, slow, and often the exact same dance on every project. You could bolt on scripts to comment on PRs or ping Slack, but then you’re duplicating logic across repos and languages and spreading around more auth tokens than you’d like.
What if instead, your build just emitted special log lines, and a wrapper tool noticed them and took action? That way your CI stays simple, projects don’t need extra secrets, and you get rich behaviour like PR comments or Slack alerts “for free.”
One way around this is to do something similar to what Xcode does where if I emit a log like warning: This is a warning
it will furnish the UI with a nice warning triangle.
So in our case if we provide an executable that knows all about pinging slack, GitHub and other services we care about we can have this program wrap our build script and look for special logs.
When it sees a log it knows how to handle it can perform the right action.
With this new parent process being responsible for executing our build script we can choose to strip out any environment variables we don’t want to share, meaning the parent process can be auth’d to talk to slack and the child will know nothing about it.
Less writing more writing code
Thankfully swift-subprocess has us pretty nicely set up for running the child process and parsing logs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Foundation
import Subprocess
let terminationStatus = try await run(
.path("/bin/bash"),
arguments: ["-c", ProcessInfo.processInfo.arguments.dropFirst().joined(separator: " ")],
error: .standardError
) { execution, standardOutput in
for try await line in standardOutput.lines() {
// Parse the log text
print(line, terminator: "")
}
}.terminationStatus
switch terminationStatus {
case let .exited(code):
exit(code)
case let .unhandledException(code):
exit(code)
}
Let’s unpack the interesting bits:
- Lines 4-8 are going to execute the command we pass in a bash subprocess.
- Line 6 is dropping the first argument as this will be the path to the executable. The rest is the command we want to execute.
- Line 7 ensures that stderr isn’t dropped on the floor as we want our command to be as transparent as possible.
- Lines 9-12 are where the magic is going to happen soon.
- Line 11 is just printing stdout otherwise our program will appear to just swallow all output.
- Lines 15-20 are just taking the result of the subprocess and making it the final result.
I haven’t explored the full API surface yet but this approach seems reasonable.
At a high level, we will invoke our new executable that I’ll call log-commander
with our normal build script something like this
log-commander bundle exec fastlane build
Under the hood log-commander
will essentially execute /bin/bash -c "bundle exec fastlane build"
and then proxy all output and the final result.
Let’s make it do something interesting
As it stands we’ve not achieved much so let’s teach log-commander
to comment on a PR.
We’ll assume that log-commander
will be provided the GITHUB_AUTH
, GITHUB_ISSUE_ID
, GITHUB_OWNER
and GITHUB_REPO
as environment variables.
With this we can create a simple client to post a comment to a PR on GitHub
class GitHubClient {
private let baseURL: URL
private let session: URLSession
init() {
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.httpAdditionalHeaders = [
"Accept": "application/vnd.github.v3+json",
"Authorization": "Bearer \(getOrFatalError("TOKEN"))",
"X-GitHub-Api-Version": "2022-11-28",
]
self.session = .init(configuration: sessionConfiguration)
self.baseURL = URL(string: getOrFatalError("URL"))!
.appending(components: "repos", getOrFatalError("OWNER"), getOrFatalError("REPO"))
}
func postComment(_ message: String) async throws {
let url = baseURL.appending(components: "issues", getOrFatalError("ISSUE_ID"), "comments")
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = try JSONEncoder().encode(["body": message])
let (_, response) = try await session.data(for: urlRequest)
if (response as? HTTPURLResponse)?.statusCode != 201 {
throw NSError(domain: "GitHubClient", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: nil)
}
}
}
private func getOrFatalError(_ key: String) -> String {
if let value = ProcessInfo.processInfo.environment["GITHUB_\(key)"] {
value
} else {
fatalError("Required 'GITHUB_\(key)' environment variable not set")
}
}
* Proper error handling is left as an exercise for the reader.
Connecting the dots
Now we have the top level code to wrap our normal build process and a client to communicate with GitHub we just need to link them together.
I’m going to go with the idea that if a line contains an opening and closing curly brace then I’ll attempt to parse it as a JSON payload.
If it parses correctly then the log-commander
will post the comment to GitHub.
Any unsuccessful decoding will just be ignored.
To achieve this we’ll introduce a structure that represents the command we care about parsing
struct Action: Decodable {
let prComment: PRComment?
struct PRComment: Decodable {
let message: String
}
}
I’m nesting the PRComment
under an Action
struct so I can build up different actions that will require different data.
With this we can update our line parsing to something like this
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
for try await line in standardOutput.lines() {
print(line, terminator: "")
guard
let openingBrace = line.firstIndex(of: "{"),
let closingBrace = line.lastIndex(of: "}"),
let message = try? decoder.decode(Action.self, from: Data(line[openingBrace...closingBrace].utf8))
else { continue }
if let prComment = message.prComment?.message {
try await client.postComment(prComment)
}
}
Now if we just emit a log statement in our normal build tool that looks like this
{"pr_comment":{"message":"Example"}}
The log-commander
will parse this out and post a comment on the PR with the message Example
.
Tidying up
I mentioned that we don’t want the child process to know about GitHub auth and this can be taken care of by manipulating the environment in the initial run
invocation something like this:
environment: .inherit.updating(["GITHUB_TOKEN": ""])
Wrap up
I like the idea of writing this logic once and then sharing it among teams to make debugging life easier. Instead of scrolling endlessly through CI logs or wiring up ad-hoc scripts for every project, you get a single wrapper that turns structured logs into actions. This blog showed GitHub PR comments but you could extend to do Slack alerts, build metrics or whatever if you can dream up. It also makes it super low friction to get rich behaviour - e.g. wrap your normal invocation to your build command with this tool and then create some specially formatted logs.