Configurando SwiftLint y CI de GitHub Actions para una app de iOS (y el runner que mintió)
Esta app se renombró después a Deep Cut Atlas. Acá abajo se la llama “Discoverer,” porque así se llamaba el día en que pasó esto.
Mi pequeña app de SwiftUI no tenía ninguna barrera de calidad automatizada: sin linter, sin CI, nada que frenara un commit descuidado. Me propuse arreglar eso: un hook de pre-commit de SwiftLint para feedback local rápido, y GitHub Actions para compilar, probar y lintear cada push a main y cada PR. El plan era una hora de YAML. Lo fue, en su mayor parte, salvo por tres cosas que solo aparecieron cuando el runner corrió de verdad.
Adoptar un linter en código que nunca tuvo uno
La primera dosis de realidad: corrí SwiftLint con su configuración por defecto y obtuve 70 violaciones. Ninguna era un error, pero una pared roja de warnings el primer día es justo la manera en que un linter termina ignorado para siempre. Así que revisé qué eran realmente antes de tocar una sola regla.
Cuarenta de las setenta eran una sola cosa: el nombre de variable vm. Uso vm para “view model” en todos lados, es una convención deliberada, no un error de tipeo. Otra era ep, el tipo de registro EP. La regla identifier_name de SwiftLint quiere nombres de tres caracteres o más, y tenía razón según su propia lógica, y estaba equivocada para mi código. Renombrar 39 sitios de uso para satisfacer a un linter habría sido dejar que la cola moviera al perro. Así que le indiqué a la regla que permitiera esos nombres específicos:
identifier_name:
excluded:
- vm # view model
- ep # EP recording type
- id
El resto caía en la misma categoría de “la herramienta tiene una opinión, y yo también.” Comas finales en literales multilínea, esas las mantengo, hacen que los diffs queden más limpios, así que desactivé la regla. Longitud de línea, subí el warning a 140 porque las cadenas de modificadores de SwiftUI son largas por naturaleza. Quedaron dos limpiezas genuinas (un inicializador redundante que el compilador sintetizaría de todos modos, una línea que sencillamente era demasiado larga), y esas sí las arreglé.
El principio al que llegué: cuando se le agrega un linter a un codebase existente, la configuración es una negociación, no una rendición. Conviene relajar las reglas que chocan con decisiones deliberadas, arreglar lo que de verdad está mal, y llegar a una corrida —strict limpia para que la barrera signifique algo.
El hook de pre-commit, sin el tiro en el pie
Para hacerlo cumplir localmente, me salteé el framework pre-commit de Python. Tiene una rareza conocida donde los filtros de archivo hacen que SwiftLint escanee todo el proyecto en vez de solo los archivos en staging, y no quería una dependencia de framework para un script de shell de quince líneas. Git soporta directamente un directorio de hooks versionado:
git config core.hooksPath .githooks
Ahora .githooks/pre-commit vive en el repo. Lintea solo los archivos Swift en staging y bloquea el commit ante cualquier violación. La decisión que vale la pena destacar: lo dejé solo para lintear. La versión tentadora corre swiftlint —fix y vuelve a poner el archivo en staging automáticamente, cómodo, hasta que pone en staging en silencio la mitad no confirmada de un archivo que estaba a medio editar. Así que el hook indica que hay que correr swiftlint —fix por cuenta propia. Un comando extra, cero sorpresas sobre qué terminó en el commit.
Después el runner corrió, y tres cosas no eran como decía la documentación
Acá es donde la hora se convirtió en una tarde entera.
La imagen del runner mintió sobre SwiftLint. La documentación de runner-images listaba a SwiftLint como preinstalado en macos-26. Mi primera corrida de CI murió en nueve segundos:
swiftlint: command not found
##[error]Process completed with exit code 127
No está en el PATH. Agregué brew install swiftlint y seguí adelante, pero la lección quedó grabada: no conviene confiar en el manifiesto de la imagen para saber si una herramienta realmente se puede invocar. Conviene verificarlo con un paso barato de —version, o directamente instalar aquello de lo que se depende. (La misma imagen también reportaba Xcode 26.4.1, no el 26.5 que tenía localmente, suficientemente cercano acá, pero vale la pena imprimirlo si el toolchain es exigente.)
Fijar un simulador a mano es una rotura en cámara lenta. GitHub mantiene solo unos tres runtimes de simulador por imagen y los nombres de dispositivo rotan, así que -destination ‘name=iPhone 16 Pro’ es un fallo futuro esperando la próxima actualización de imagen. En su lugar, resuelvo un iPhone disponible en tiempo de ejecución:
UDID=$(xcrun simctl list devices available --json \
| jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid')
Después -destination “id=$UDID”. jq ya está en el runner. Simplemente encuentra cualquier iPhone que exista y lo usa.
El CI no corrió cuando subí la rama. Mis triggers eran push a main y pull_request. Subí la rama de feature y… nada. Claro, un push a una rama no es un push a main, y todavía no había PR, así que ningún evento se disparaba. Abrir el PR disparó pull_request y la corrida finalmente arrancó. Obvio en retrospectiva, brevemente desconcertante en el momento.
Lo que costó y lo que valió
Los runners de macOS se cobran a diez veces la tarifa de Linux, así que lo dejé en un solo job: lint, después build, después test, un único arranque de runner, lint primero para que un desliz de estilo falle rápido y barato, más una regla de concurrencia para cancelar corridas reemplazadas. El check verde en el PR valió la tarde. No porque el YAML fuera difícil, sino porque cada una de esas tres sorpresas es del tipo que habría fallado en silencio o de forma confusa más adelante, en el push de otra persona. Mejor encontrarlas en el push en el que yo estaba prestando atención.
Lecturas relacionadas
Fijar la versión de SwiftLint en CI cuando Homebrew no lo permite
brew install swiftlint significa "lo que sea estable hoy," y la ejecución de CI más expuesta a esa deriva es la del tag de release. El punto de anclaje es el binario portátil, no una fórmula.
Solo arreglé la captura que me pidieron, no las otras que también estaban rotas
Una captura de marketing recapturada parecía terminada, hasta la pregunta desinflante: ¿de verdad esta es toda captura que se había pedido? Las otras tres del mismo conjunto también estaban desactualizadas, cada una a su manera.
Una sección de tracklist, y por qué tomó 30 minutos
Un método de protocolo, un enum de estado reutilizado, una convención de enrutamiento que se mantuvo, y un límite de lint que forzó una división que valía la pena hacer de todos modos.