CI/CD minimalista com GitHub Actions: pipeline completo para projetos PHP
Um pipeline que roda em menos de dois minutos, pega o que precisa ser pego e cabe em um arquivo. Cache de Composer que acerta a chave, Pest com cobertura, PHPStan, serviços de banco e deploy com OIDC em vez de chave estática.
▸Neste artigo
Já herdei pipeline com 400 linhas de YAML que ninguém do time sabia explicar inteiro. Rodava em onze minutos, quebrava por motivo aleatório uma vez por semana, e a resposta padrão para falha vermelha virou “roda de novo”.
Um pipeline nesse estado é pior que não ter pipeline. Ele consome tempo, treina o time a ignorar o vermelho, e ainda dá a falsa sensação de que existe uma rede de proteção.
O que eu procuro hoje: rodar em menos de dois minutos, falhar só quando algo está de fato errado, e caber num arquivo que dá para ler numa tela. Este é o pipeline que eu monto para projetos PHP e Laravel, construído em camadas.
O que um pipeline mínimo precisa fazer
Antes do YAML, o acordo. Um CI útil responde três perguntas, nessa ordem de custo:
- O código segue o padrão? (segundos)
- Os tipos fazem sentido? (dezenas de segundos)
- O comportamento está correto? (minutos)
Se o passo 1 falha, não faz sentido gastar dois minutos rodando teste. Ordenar do mais barato para o mais caro é a otimização mais simples que existe, e quase ninguém faz.
O que não entra no CI: teste manual disfarçado de automatizado, verificação que depende de serviço externo instável, e qualquer coisa que o time aprendeu a ignorar quando fica vermelha.
A base: um job que instala e valida
name: CI
on:
push:
branches: [main]
pull_request:
# Cancela execuções antigas do mesmo branch quando você empurra de novo.
# Economiza minutos de runner e evita fila em PR movimentado.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
qualidade:
name: Qualidade de código
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Configurar PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_mysql, redis, bcmath
coverage: none
tools: composer:v2
- name: Instalar dependências
run: composer install --prefer-dist --no-progress --no-interaction
- name: Formatação (Pint)
run: vendor/bin/pint --test
- name: Análise estática (PHPStan)
run: vendor/bin/phpstan analyse --memory-limit=1GTrês detalhes que fazem diferença:
concurrency com cancel-in-progress. Sem isso, cada push num PR ativo
enfileira outra execução completa. Com cinco pessoas trabalhando, a fila vira o
gargalo.
coverage: none no job de qualidade. Habilitar Xdebug ou PCOV custa tempo
de instalação e deixa o PHP mais lento. Só ligue no job que realmente mede
cobertura.
timeout-minutes. O padrão do GitHub é 360 minutos. Um job travado sem
timeout queima seis horas de runner antes de desistir.
Cache do Composer que realmente acerta
Este é o passo que quase todo tutorial erra, e a diferença é entre 45 segundos e 5 segundos por execução.
O erro comum é cachear vendor/. Não faça isso: vendor/ depende da versão do
PHP e das extensões, e restaurar um vendor/ construído com outra configuração
gera erro difícil de diagnosticar. Cacheie o cache do Composer.
- name: Descobrir o diretório de cache do Composer
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Restaurar cache
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
# A chave inclui a versão do PHP: cache de 8.2 não serve para 8.3
key: composer-${{ runner.os }}-php8.3-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-php8.3-
- name: Instalar dependências
run: composer install --prefer-dist --no-progress --no-interactionO restore-keys é o que salva o dia quando o composer.lock muda: a chave exata
falha, mas o prefixo casa com o cache anterior, e o Composer só baixa o que
mudou de fato — em vez de tudo de novo.
Se o projeto não tem composer.lock versionado, comece por aí. Sem lock, o
cache não tem chave estável e as execuções não são reproduzíveis.
Testes com serviços de verdade
Aqui entra o recurso mais subutilizado do GitHub Actions: service containers. Em vez de mockar o banco ou instalar MySQL na mão, você declara os serviços e o runner sobe cada um em Docker.
testes:
name: Testes (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: senha
MYSQL_DATABASE: teste
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -psenha"
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis:7-alpine
ports: ['6379:6379']
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-retries=5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, pdo_mysql, redis, bcmath
coverage: pcov
- run: composer install --prefer-dist --no-progress --no-interaction
- name: Preparar ambiente
run: |
cp .env.example .env
php artisan key:generate
- name: Migrar
run: php artisan migrate --force
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_DATABASE: teste
DB_USERNAME: root
DB_PASSWORD: senha
- name: Rodar testes
run: vendor/bin/pest --coverage --min=70 --parallel
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_DATABASE: teste
DB_USERNAME: root
DB_PASSWORD: senha
REDIS_HOST: 127.0.0.1Pontos que causam dor de cabeça se ignorados:
As options de health check não são opcionais. Sem elas, o job começa antes
do MySQL aceitar conexão e você recebe Connection refused intermitente — o pior
tipo de falha, porque some quando você tenta reproduzir.
Use 127.0.0.1, não localhost. Com localhost, o PHP pode tentar socket
Unix em vez de TCP e não encontrar o serviço.
fail-fast: false. Por padrão, a falha numa versão cancela as outras. Você
quer saber se quebrou só no 8.4 ou em todas.
pcov em vez de xdebug. Para cobertura, o PCOV é várias vezes mais rápido.
Xdebug só quando você precisa de debug passo a passo, o que não é o caso no CI.
--parallel no Pest. Divide a suíte entre os núcleos do runner. Exige que os
testes sejam independentes — e se não forem, você acabou de descobrir um
problema real.
Análise estática que agrega
PHPStan sem configuração reclama de tudo e o time desliga. A saída é o baseline: congele a dívida existente e impeça que ela cresça.
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 6
paths:
- app
- database
- routes
ignoreErrors:
- '#PHPDoc tag @var#'
checkMissingIterableValueType: falseGere o baseline uma vez com
vendor/bin/phpstan analyse --generate-baseline, e a partir daí o CI só falha em
erro novo. Suba o level um degrau por sprint e regenere. É a única forma que já
vi funcionar em código legado grande.
Deploy
Com a validação em pé, o deploy fica curto. Só roda em main e só depois de o CI
passar.
name: Deploy
on:
workflow_run:
workflows: [CI]
branches: [main]
types: [completed]
permissions:
contents: read
id-token: write # necessário para OIDC
jobs:
publicar:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: producao # habilita aprovação manual e segredos por ambiente
steps:
- uses: actions/checkout@v4
# Sem chave de acesso guardada. O GitHub troca um token de curta
# duração com a AWS a cada execução.
- name: Autenticar na AWS via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: sa-east-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- name: Construir e publicar imagem
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.ecr.outputs.registry }}/minha-api:${{ github.sha }}
${{ steps.ecr.outputs.registry }}/minha-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Atualizar serviço
run: |
aws ecs update-service \
--cluster producao \
--service minha-api \
--force-new-deploymentOIDC em vez de chave estática
Vale insistir neste ponto porque ainda vejo AWS_SECRET_ACCESS_KEY em
secrets por aí.
Uma chave estática no repositório é uma credencial permanente que: nunca expira sozinha, funciona de qualquer lugar do mundo se vazar, e precisa de rotação manual que ninguém faz.
Com OIDC, o GitHub apresenta um token assinado provando “sou o workflow X do repositório Y no branch main”, e a AWS devolve credenciais válidas por alguns minutos. Nada persistente para vazar.
A configuração no lado da AWS é uma política de confiança na role:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:sua-org/seu-repo:ref:refs/heads/main"
}
}
}]
}O StringLike no sub é o que trava o acesso ao branch main do seu
repositório. Sem essa condição — ou com um curinga largo demais — qualquer
repositório do GitHub pode assumir sua role. Já vi isso configurado como
repo:*, o que é o equivalente a deixar a chave publicada.
Se você faz deploy em VPS
Nem todo projeto está em container. Para servidor tradicional, o Deployer resolve com zero downtime:
- name: Configurar chave SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Publicar
run: vendor/bin/dep deploy producaoO Deployer faz release em diretório novo, roda migrations e só então troca o
symlink. Se algo falhar no meio, o symlink antigo continua servindo — e
dep rollback volta em segundos.
O que não colocar no pipeline
Coisas que já vi engordarem CI sem entregar nada:
Build de assets em todo PR. Se o front-end não mudou, é minuto queimado.
Use paths-filter para rodar só quando arquivos relevantes mudarem.
Teste que depende de API externa. O CI passa a falhar quando o serviço de terceiro tem instabilidade, e o time aprende a ignorar vermelho. Mocke no CI, teste a integração real num job agendado separado.
Linter para tudo que existe. Escolha um formatador (Pint) e um analisador (PHPStan). Sobrepor três ferramentas que discordam entre si só gera atrito.
Deploy automático sem portão. environment: producao com aprovação exigida
custa dez segundos de clique e já evitou muito incidente de sexta à noite.
Quanto custa
Para repositório público, GitHub Actions é gratuito. Para privado, o plano Free dá 2.000 minutos por mês, e runner Linux consome 1 minuto por minuto real.
O pipeline deste artigo, num projeto Laravel de tamanho médio:
| Job | Sem cache | Com cache |
|---|---|---|
| Qualidade | 55 s | 22 s |
| Testes (por versão de PHP) | 2 min 10 s | 1 min 05 s |
| Deploy | 3 min 30 s | 1 min 20 s |
Com matriz de três versões, um PR consome cerca de 4 minutos. São mais ou menos 500 PRs por mês dentro da cota gratuita — bem acima do que a maioria dos times produz.
Se você estourar: reduza a matriz para a versão de produção nos PRs e rode as
outras só no main, ou num agendamento noturno.
Checklist
composer.lockversionado — sem ele o cache não funcionaconcurrencycomcancel-in-progressligado- Ordem do mais barato ao mais caro: formatação → tipos → testes
- Cache do diretório do Composer, com a versão do PHP na chave
- Service containers com health check configurado
127.0.0.1em vez delocalhostnas variáveis de conexão- PCOV para cobertura, nunca Xdebug
- Baseline do PHPStan em projeto legado, com nível subindo aos poucos
- OIDC em vez de chave estática, com
subrestrito ao repositório e branch environmentcom aprovação para produção
O melhor pipeline não é o mais completo. É aquele em que o time confia — porque quando fica vermelho, todo mundo sabe que tem algo errado de verdade.