Pinning SwiftLint in CI when Homebrew won't let you
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.
Small issue, quick fix, but the kind of thing that’s obvious once you know it and easy to miss otherwise.
Problem
Discoverer’s CI installs SwiftLint with brew install swiftlint before running swiftlint lint --strict. That line looks completely inert - it’s just installing a tool - but it’s actually installing whatever Homebrew’s current stable release is on the day the job runs. My local pre-commit hook was validated against 0.63.3. By the time I looked at this, Homebrew’s stable had already moved to 0.65.0.
That means CI and my local machine could silently be running two different linters. If a new SwiftLint version ships stricter default rules, or changes how an existing rule counts violations, a CI run can fail with zero code changes on my end - and this CI only runs on release tags, so that failure shows up right when I’m trying to ship a build, not during regular day-to-day work.
Why this approach
My first instinct was to just pin a Homebrew formula version, the way you’d do brew install node@18. Doesn’t exist for SwiftLint - there’s no swiftlint@0.63.3 formula, only the single rolling swiftlint formula that always tracks latest stable.
SwiftLint does publish a portable_swiftlint.zip on every GitHub release though - a self-contained binary bundle, not tied to Homebrew’s release cadence at all. That’s the actual pin point.
Implementation
- name: Install SwiftLint (pinned)
env:
SWIFTLINT_VERSION: "0.63.3"
run: |
curl -sSL -o swiftlint.zip \
"https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip"
unzip -q swiftlint.zip -d "$RUNNER_TEMP/swiftlint"
echo "$RUNNER_TEMP/swiftlint" >> "$GITHUB_PATH"
Download the exact version’s asset, unzip it somewhere in the runner’s temp space, add that directory to PATH. No sudo, no touching system directories. I picked 0.63.3 specifically because that’s the version I’d already been running against this codebase all along - pinning to whatever’s newest would just be swapping one kind of drift for another, except now it’s a drift I haven’t actually checked against my own lint config yet.
While I was in the CI file, I also added -resultBundlePath to the test step and an actions/upload-artifact step gated on if: failure(). Before this, a failing tag-triggered run left me with nothing but the raw console log to figure out what broke. Now the full xcresult bundle - assertion detail, screenshots if a UI test failed - gets uploaded automatically, but only when something actually fails, so it’s not cluttering up every successful run with an unnecessary artifact.
Gotchas
The local side of this can’t be forced the same way without adding real friction. There’s still no versioned Homebrew formula, so a contributor’s local install stays “whatever’s current” unless they also go download a portable binary by hand - not worth the setup tax for what’s currently a solo project. Instead I just left a one-line comment next to the README’s brew install swiftlint line pointing at the exact version CI pins, so if I ever bump one I remember to bump the other on purpose instead of by accident.
actions/upload-artifact also turned out to be on v7 already - I’d have guessed v4 from memory, which is a good reminder to actually check a tool’s current release instead of trusting what “sounds current.”
Results
Triggered the workflow manually against the branch before merging (this repo’s CI is tag/dispatch-only, not per-push, to control macOS runner costs) and confirmed swiftlint --version in the CI log printed exactly 0.63.3 - not whatever Homebrew’s stable happened to be that day. Small fix, but it closes a real gap: the exact CI run that matters most (a release tag) is the one that was most exposed to an unreviewed dependency bump breaking it for reasons that have nothing to do with the actual code being shipped.
Related reading
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.
Wiring up SwiftLint and GitHub Actions CI for an iOS app (and the runner that lied)
Adopting a linter on code that never had one, a framework-free pre-commit hook, and three things the macOS runner did that the docs didn't mention.