Skip to content
Development

Wiring up SwiftLint and GitHub Actions CI for an iOS app (and the runner that lied)

By Victor Da Luz
iosswiftswiftlintgithub-actionscidev-logdeep-cut-atlas

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