Voltar ao Blog
DevOps9 min de leitura

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:

  1. O código segue o padrão? (segundos)
  2. Os tipos fazem sentido? (dezenas de segundos)
  3. 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

.github/workflows/ci.yml
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=1G

Trê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.

Cache correto
      - 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-interaction

O 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.

Job de testes
  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.1

Pontos 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.

phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon
    - phpstan-baseline.neon

parameters:
    level: 6
    paths:
        - app
        - database
        - routes

    ignoreErrors:
        - '#PHPDoc tag @var#'

    checkMissingIterableValueType: false

Gere 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.

.github/workflows/deploy.yml
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-deployment

OIDC 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:

Trust policy da role IAM
{
  "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:

Deploy em VPS
      - 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 producao

O 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

  1. composer.lock versionado — sem ele o cache não funciona
  2. concurrency com cancel-in-progress ligado
  3. Ordem do mais barato ao mais caro: formatação → tipos → testes
  4. Cache do diretório do Composer, com a versão do PHP na chave
  5. Service containers com health check configurado
  6. 127.0.0.1 em vez de localhost nas variáveis de conexão
  7. PCOV para cobertura, nunca Xdebug
  8. Baseline do PHPStan em projeto legado, com nível subindo aos poucos
  9. OIDC em vez de chave estática, com sub restrito ao repositório e branch
  10. environment com 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.

CI/CDGitHub ActionsPHPLaravelDocker

Leia também