Skip to content
Development

Pinning SwiftLint in CI when Homebrew won't let you

By Victor Da Luz
iosswiftcidev-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.

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