Pular para o conteúdo
Development

Configurando SwiftLint e CI do GitHub Actions para um app iOS (e o runner que mentiu)

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

Este app foi renomeado depois para Deep Cut Atlas. Abaixo ele é chamado de “Discoverer” o tempo todo, porque foi assim que se chamava no dia em que isso aconteceu.

Meu pequeno app SwiftUI não tinha nenhum gate automatizado de qualidade: sem linter, sem CI, nada impedindo um commit relapso. Decidi resolver isso: um hook de pre-commit do SwiftLint para feedback local rápido, e GitHub Actions para buildar, testar e lintar cada push na main e cada PR. O plano era uma hora de YAML. Foi quase isso, exceto por três coisas que só apareceram quando o runner realmente rodou.

Adotando um linter em um código que nunca teve um

A primeira dose de realidade: rodei o SwiftLint com as configurações padrão e recebi 70 violações. Nenhuma era erro, mas uma parede vermelha de warnings no primeiro dia é como um linter acaba sendo ignorado para sempre. Então olhei o que elas realmente eram antes de mexer em qualquer regra.

Quarenta das setenta eram uma única coisa: o nome de variável vm. Uso vm para “view model” em todo lugar, é uma convenção deliberada, não um erro de digitação. Outra era ep, o tipo de registro EP. A regra identifier_name do SwiftLint quer nomes com três caracteres ou mais, e ela estava certa pela própria lógica dela e errada para o meu código. Renomear 39 pontos de uso para satisfazer um linter seria o rabo abanando o cachorro. Então falei para a regra permitir esses nomes específicos:

identifier_name:
  excluded:
    - vm # view model
    - ep # EP recording type
    - id

O resto caiu na mesma categoria de “a ferramenta tem uma opinião, e eu também tenho a minha”. Vírgulas finais em literais multilinha: eu mantenho essas, elas deixam os diffs mais limpos, então desabilitei a regra. Comprimento de linha: aumentei o aviso para 140 porque cadeias de modifiers do SwiftUI são longas por natureza. Sobraram duas limpezas genuínas (um initializer redundante que o compilador sintetizaria de qualquer jeito, uma linha que era simplesmente longa demais), e essas eu de fato corrigi.

O princípio a que cheguei: quando você acopla um linter a uma base de código já existente, a configuração é uma negociação, não uma rendição. Relaxe as regras que brigam com suas escolhas deliberadas, corrija o que está realmente errado, e chegue a uma execução limpa com --strict para que o gate signifique alguma coisa.

O hook de pre-commit, sem a armadilha

Para a checagem local, pulei o framework Python pre-commit. Ele tem uma peculiaridade conhecida em que os filtros de arquivo fazem o SwiftLint escanear o projeto inteiro em vez dos arquivos staged, e eu não queria uma dependência de framework para um script shell de quinze linhas. O Git suporta diretamente um diretório de hooks versionado:

git config core.hooksPath .githooks

Agora .githooks/pre-commit vive no repositório. Ele linta só os arquivos Swift staged e bloqueia o commit em qualquer violação. A decisão que vale destacar: fiz dele algo apenas de checagem. A versão tentadora roda swiftlint --fix e faz o re-stage do arquivo para você, conveniente, até fazer o stage silencioso da metade não staged de um arquivo que você estava editando pela metade. Então o hook diz para você rodar swiftlint --fix você mesmo. Um comando a mais, zero surpresas sobre o que foi commitado.

Aí o runner rodou, e três coisas não eram como a documentação dizia

Aqui é onde a hora virou uma tarde.

A imagem do runner mentiu sobre o SwiftLint. A documentação das runner-images listava o SwiftLint como pré-instalado no macos-26. Minha primeira execução de CI morreu em nove segundos:

swiftlint: command not found
##[error]Process completed with exit code 127

Ele não está no PATH. Adicionei brew install swiftlint e segui em frente, mas a lição ficou: não confie no manifesto da imagem para saber se uma ferramenta realmente pode ser chamada. Verifique com um passo barato de --version, ou simplesmente instale o que você depende. (A mesma imagem também reportava Xcode 26.4.1, não o 26.5 que eu tinha localmente, próximo o suficiente aqui, mas vale imprimir isso se seu toolchain for sensível a isso.)

Fixar um simulador no código é uma quebra em câmera lenta. O GitHub mantém só cerca de três runtimes de simulador por imagem e os nomes de dispositivo giram, então -destination 'name=iPhone 16 Pro' é uma falha futura esperando a próxima atualização de imagem. Em vez disso, resolvo um iPhone disponível em tempo de execução:

UDID=$(xcrun simctl list devices available --json \
  | jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid')

Depois -destination "id=$UDID". O jq já está no runner. Ele simplesmente encontra qualquer iPhone que exista e o usa.

O CI não rodou quando fiz push da branch. Meus triggers eram push na main e pull_request. Fiz push da feature branch e… nada. Claro, um push de branch não é um push na main, e ainda não existia PR, então nenhum dos dois eventos dispara. Abrir o PR disparou o pull_request e a execução finalmente começou. Óbvio em retrospecto, brevemente desconcertante no momento.

O que custou e o que vale

Runners macOS cobram dez vezes a taxa do Linux, então mantive em um único job: lint, depois build, depois test, um único runner sendo iniciado, lint primeiro para que um deslize de estilo falhe rápido e barato, mais uma regra de concorrência para cancelar execuções substituídas. O check verde no PR valeu a tarde. Não porque o YAML era difícil, mas porque cada uma dessas três surpresas é o tipo de coisa que teria falhado silenciosamente ou de forma confusa depois, no push de outra pessoa. Melhor encontrá-las naquele em que eu estava prestando atenção.

Leitura relacionada

Development

Uma seção de tracklist, e por que levou 30 minutos

Um método de protocolo, um enum de estado reaproveitado, uma convenção de roteamento que se manteve, e um orçamento de lint que forçou uma divisão que valia a pena fazer de qualquer forma.

Ler
Development

A playlist que já tinha o nome certo

Um artefato de renomeação, uma API sem campo de autor pra atualizar, e um teste em dispositivo real provando que o bug já tinha se corrigido sozinho, fechado como aceitar como está.

Ler

Você também pode achar útil

Proton

Proton Drive

Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais
Proton

Proton Pass

Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais