Wiring up SwiftLint and GitHub Actions CI for an iOS app (and the runner that lied)
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
My small SwiftUI app had no automated quality gates - no linter, no CI, nothing stopping a sloppy commit. I set out to fix that: a SwiftLint pre-commit hook for fast local feedback, and GitHub Actions to build, test, and lint every push to main and every PR. The plan was an hour of YAML. It mostly was, except for three things that only showed up once the runner actually ran.
Adopting a linter on code that never had one
The first reality check: I ran SwiftLint with its defaults and got 70 violations. None were errors, but a red wall of warnings on day one is how a linter gets ignored forever. So I looked at what they actually were before touching a single rule.
Forty of the seventy were one thing: the variable name vm. I use vm for “view model” everywhere - it’s a deliberate convention, not a typo. Another was ep, the EP record type. SwiftLint’s identifier_name rule wants names three characters or longer, and it was right by its own logic and wrong for my code. Renaming 39 call sites to satisfy a linter would be the tail wagging the dog. So I told the rule to allow those specific names:
identifier_name:
excluded:
- vm # view model
- ep # EP recording type
- id
The rest fell into the same bucket of “the tool has an opinion, and so do I.” Trailing commas in multiline literals - I keep those, they make diffs cleaner, so I disabled the rule. Line length - I bumped the warning to 140 because SwiftUI modifier chains are long by nature. Two genuine cleanups were left over (a redundant initializer the compiler would synthesize anyway, one line that was just too long), and those I actually fixed.
The principle I landed on: when you bolt a linter onto an existing codebase, the config is a negotiation, not a surrender. Relax the rules that fight your deliberate choices, fix the things that are actually wrong, and get to a clean --strict run so the gate means something.
The pre-commit hook, minus the footgun
For local enforcement I skipped the Python pre-commit framework. It has a known quirk where file filters make SwiftLint scan the whole project instead of staged files, and I didn’t want a framework dependency for a fifteen-line shell script. Git supports a tracked hooks directory directly:
git config core.hooksPath .githooks
Now .githooks/pre-commit lives in the repo. It lints only the staged Swift files and blocks the commit on any violation. The one decision worth calling out: I made it lint-only. The tempting version runs swiftlint --fix and re-stages the file for you - convenient, until it silently stages the unstaged half of a file you were mid-edit on. So the hook tells you to run swiftlint --fix yourself. One extra command, zero surprises about what got committed.
Then the runner ran, and three things were not as documented
Here’s where the hour turned into an afternoon.
The runner image lied about SwiftLint. The runner-images docs listed SwiftLint as preinstalled on macos-26. My first CI run died in nine seconds:
swiftlint: command not found
##[error]Process completed with exit code 127
It is not on the PATH. I added brew install swiftlint and moved on, but the lesson stuck: don’t trust the image manifest for whether a tool is actually callable. Verify it with a cheap --version step, or just install what you depend on. (The same image also reported Xcode 26.4.1, not the 26.5 I had locally - close enough here, but worth printing if your toolchain is picky.)
Hardcoding a simulator is a slow-motion break. GitHub keeps only about three simulator runtimes per image and the device names rotate, so -destination 'name=iPhone 16 Pro' is a future failure waiting for the next image update. I resolve an available iPhone at runtime instead:
UDID=$(xcrun simctl list devices available --json \
| jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid')
Then -destination "id=$UDID". jq is already on the runner. It just finds whatever iPhone exists and uses it.
CI didn’t run when I pushed the branch. My triggers were push to main and pull_request. I pushed the feature branch and… nothing. Of course - a branch push isn’t a push to main, and there’s no PR yet, so neither event fires. Opening the PR triggered pull_request and the run finally started. Obvious in hindsight, briefly baffling in the moment.
What it cost and what it’s worth
macOS runners bill at ten times the Linux rate, so I kept it to one job - lint, then build, then test, single runner spin-up, lint first so a style slip fails fast and cheap - plus a concurrency rule to cancel superseded runs. The green check on the PR was worth the afternoon. Not because the YAML was hard, but because every one of those three surprises is the kind of thing that would have failed silently or confusingly later, on someone else’s push. Better to find them on the one where I was paying attention.
Related reading
Pinning SwiftLint in CI when Homebrew won't let you
brew install swiftlint means "whatever's stable today," and the CI run most exposed to that drift is the release tag. The pin point is the portable binary, not a formula.
I only fixed the screenshot I was asked about, not the ones that were also broken
One recaptured marketing shot looked done - until the deflating question: is this really every screenshot? The other three in the same set were stale too, each in a different way.
A tracklist section, and why it took 30 minutes
One protocol method, a reused state enum, a routing convention that held, and a lint budget that forced a split worth making anyway.