CI/CD — Integração Contínua e Entrega Contínua — é a prática de automatizar a verificação, teste e entrega de software a cada mudança no código. Sem CI/CD, testes são executados manualmente, deploys são processos manuais propensos a erros e bugs chegam à produção sem serem detectados. GitHub Actions é a plataforma nativa do GitHub para CI/CD — gratuita para projetos públicos, integrada ao repositório e com um ecossistema imenso de ações reutilizáveis.
Conceitos Fundamentais
Developer GitHub Servidor
│ │ │
│ git push ──► │ │
│ │ │
│ Trigger Event │
│ │ │
│ ┌────▼────┐ │
│ │ Workflow │ │
│ │ CI/CD │ │
│ └────┬────┘ │
│ │ │
│ ┌────▼────────────────┐ │
│ │ Job: test │ │
│ │ - lint │ │
│ │ - type check │ │
│ │ - pytest │ │
│ │ - coverage │ │
│ └────┬────────────────┘ │
│ │ │
│ ┌────▼────────────────┐ │
│ │ Job: build │ │
│ │ - docker build │ │
│ │ - docker push │ │
│ └────┬────────────────┘ │
│ │ │
│ ┌────▼────────────────┐ │
│ │ Job: deploy │ │──► Deploy
│ │ - deploy staging │ │
│ │ - smoke tests │ │
│ │ - deploy prod │ │
│ └─────────────────────┘ │
Estrutura de um Workflow
.github/
└── workflows/
├── ci.yml ← testes e qualidade
├── cd.yml ← deploy
├── release.yml ← publicação de releases
└── schedule.yml ← tarefas agendadas
Um workflow é um arquivo YAML com quatro peças:
- Triggers — quando ele é executado;
- Jobs — grupos de steps que rodam em paralelo ou em sequência;
- Steps — comandos individuais ou actions reutilizáveis;
- Runners — as máquinas virtuais onde os jobs rodam.
Workflow de CI Completo
# .github/workflows/ci.yml
name: CI — Testes e Qualidade
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch: # permite execução manual
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # cancela runs anteriores do mesmo PR
env:
PYTHON_VERSION: "3.12"
POETRY_VERSION: "1.8.0"
jobs:
# ── JOB 1: Qualidade de Código ────────────────────────
qualidade:
name: Qualidade de Código
runs-on: ubuntu-latest
steps:
- name: Checkout do código
uses: actions/checkout@v4
- name: Configurar Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Instalar dependências de qualidade
run: pip install ruff black mypy bandit
- name: Ruff — linting
run: ruff check src/ tests/
- name: Black — formatação
run: black --check src/ tests/
- name: mypy — type checking
run: mypy src/ --ignore-missing-imports
- name: Bandit — segurança
run: bandit -r src/ -ll # só erros médios e altos
# ── JOB 2: Testes ─────────────────────────────────────
testes:
name: Testes Python ${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
needs: qualidade # só roda se qualidade passar
strategy:
fail-fast: false # não cancela outras versões se uma falhar
matrix:
python-version: ["3.11", "3.12"]
os: [ubuntu-latest, windows-latest, macos-latest]
exclude:
# Evita combinações desnecessárias
- os: windows-latest
python-version: "3.11"
- os: macos-latest
python-version: "3.11"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configurar Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Instalar dependências
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Executar testes com cobertura
run: |
pytest tests/ \
--cov=src \
--cov-report=xml \
--cov-report=term-missing \
--cov-fail-under=85 \
-v \
--tb=short
- name: Enviar cobertura ao Codecov
uses: codecov/codecov-action@v4
if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: false
# ── JOB 3: Testes de Integração ───────────────────────
integracao:
name: Testes de Integração
runs-on: ubuntu-latest
needs: testes
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--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-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://test_user:test_pass@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -r requirements.txt -r requirements-dev.txt
- name: Executar migrações
run: alembic upgrade head
- name: Testes de integração
run: pytest tests/integration/ -v --tb=short -m integration
# ── JOB 4: Build da Imagem Docker ─────────────────────
docker:
name: Build Docker
runs-on: ubuntu-latest
needs: [testes, integracao]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login no GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extrair metadados
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=semver,pattern={{version}}
- name: Build e Push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Workflow de CD: Deploy Automático
# .github/workflows/cd.yml
name: CD — Deploy
on:
push:
tags:
- "v*.*.*"
# push na main NÃO entra aqui: o deploy espera o CI terminar
workflow_run:
workflows: ["CI — Testes e Qualidade"]
types: [completed]
branches: [main]
jobs:
deploy-staging:
name: Deploy para Staging
runs-on: ubuntu-latest
if: >
github.event_name == 'push' ||
github.event.workflow_run.conclusion == 'success'
environment:
name: staging
url: https://staging.escola.com
steps:
- uses: actions/checkout@v4
- name: Configurar SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_STAGING_KEY }}
- name: Deploy para Staging
run: |
ssh ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} << 'EOF'
cd /app/escola
# o commit que o CI aprovou, não o que estiver na ponta da main
git fetch origin
git checkout --detach ${{ github.event.workflow_run.head_sha || github.sha }}
docker compose pull
docker compose up -d --no-deps --build api
docker compose exec api alembic upgrade head
echo "Deploy staging concluído!"
EOF
- name: Smoke Tests no Staging
run: |
sleep 10 # aguarda o serviço iniciar
curl -f https://staging.escola.com/health || exit 1
curl -f https://staging.escola.com/alunos || exit 1
- name: Notificar Slack
if: always()
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"text": "Deploy Staging: ${{ job.status }} — ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
deploy-producao:
name: Deploy para Produção
runs-on: ubuntu-latest
needs: deploy-staging
if: startsWith(github.ref, 'refs/tags/v')
environment:
name: production
url: https://escola.com
steps:
- uses: actions/checkout@v4
- name: Aprovação manual
uses: trstringer/manual-approval@v1
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: prof-ricardo-matos,tech-lead
minimum-approvals: 1
issue-title: "Deploy ${{ github.ref_name }} para produção" # ref_name já é "v1.2.3"
- name: Deploy para Produção
run: |
echo "Deployando ${{ github.ref_name }} para produção..."
- name: Criar Release no GitHub
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
token: ${{ secrets.GITHUB_TOKEN }}
A primeira versão deste workflow disparava também com push na main, e a condição do job aceitava github.event_name == 'push'. O efeito era um deploy imediato a cada push, antes de o CI rodar, e um segundo deploy quando o CI terminava, disparado pelo workflow_run. Um commit que quebrasse os testes chegava ao staging do mesmo jeito. Com o push restrito às tags, a main só é implantada pelo workflow_run, e a condição conclusion == 'success' passa a valer. Outro detalhe do workflow_run: ele roda no contexto da branch padrão, e o commit que o CI testou está em github.event.workflow_run.head_sha. Por isso o servidor faz checkout desse commit, e não um git pull, que traria qualquer coisa enviada à main nesse meio-tempo. E o título da issue perdeu um "v": para a tag v1.2.3, o github.ref_name já vale v1.2.3, e o texto antigo virava "Deploy vv1.2.3".
Secrets e Variáveis de Ambiente
# Usando secrets no workflow
steps:
- name: Configurar credenciais
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
API_KEY: ${{ secrets.EXTERNAL_API_KEY }}
run: echo "Credenciais configuradas"
# Secrets de um ambiente (staging/produção): o environment
# vale para o JOB inteiro, não para um step
jobs:
deploy:
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy com credenciais do ambiente
env:
HOST: ${{ secrets.STAGING_HOST }}
run: ./scripts/deploy.sh
# Configurando secrets via GitHub CLI
gh secret set DATABASE_URL --body "postgresql://..."
gh secret set SECRET_KEY --body "minha-chave-secreta"
# Listando secrets
gh secret list
# Secrets por ambiente
gh secret set DATABASE_URL \
--env staging \
--body "postgresql://staging-host/db"
Cache de Dependências
# Cache inteligente — só reinstala se requirements mudar
# (dispensável quando o setup-python já usa cache: pip, como nos jobs acima)
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
# Cache para Poetry
- name: Cache Poetry
uses: actions/cache@v4
with:
path: |
~/.cache/pypoetry
.venv
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
# Cache para Docker layers — só para builds fora do build-push-action;
# com ele, cache-from/cache-to: type=gha já resolvem
- name: Cache Docker
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
Workflow de Release Automatizado
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # histórico completo para changelog
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Instalar dependências
run: pip install build twine
- name: Build do pacote
run: python -m build
- name: Verificar distribuição
run: twine check dist/*
- name: Publicar no PyPI
if: startsWith(github.ref, 'refs/tags/')
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: twine upload dist/*
- name: Gerar Changelog
id: changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Criar GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.changelog.outputs.changelog }}
files: dist/*
token: ${{ secrets.GITHUB_TOKEN }}
Workflow Agendado: Tarefas Periódicas
# .github/workflows/schedule.yml
name: Tarefas Agendadas
on:
schedule:
# O cron do GitHub Actions é sempre em UTC
- cron: "0 6 * * 1-5" # 6h UTC (3h em Brasília), seg a sex
- cron: "0 2 * * 0" # 2h UTC de domingo (23h de sábado em Brasília)
jobs:
backup:
name: Backup Semanal
runs-on: ubuntu-latest
if: github.event.schedule == '0 2 * * 0'
steps:
- uses: actions/checkout@v4
- run: python scripts/backup.py
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
relatorio-diario:
name: Relatório Diário
runs-on: ubuntu-latest
if: github.event.schedule == '0 6 * * 1-5'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: |
pip install -r requirements.txt
python scripts/gerar_relatorio.py
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
seguranca:
name: Auditoria de Dependências
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: pip-audit
run: |
pip install pip-audit
pip-audit -r requirements.txt --format=json \
-o relatorio_seguranca.json || true # só relata, nunca falha o job
- uses: actions/upload-artifact@v4
with:
name: relatorio-seguranca
path: relatorio_seguranca.json
Reutilizando Workflows
# .github/workflows/_testes.yml — workflow reutilizável
name: Testes (reutilizável)
on:
workflow_call:
inputs:
python-version:
required: false
type: string
default: "3.12"
secrets:
CODECOV_TOKEN:
required: false
jobs:
testes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest tests/ --cov=src --cov-report=xml
# .github/workflows/ci.yml — usando o workflow reutilizável
on: [push, pull_request]
jobs:
testes-312:
uses: ./.github/workflows/_testes.yml
with:
python-version: "3.12"
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
A primeira versão deste exemplo tinha dois defeitos. O primeiro era de forma: a definição e o uso estavam no mesmo arquivo, com duas chaves jobs:, e o actionlint recusa com key "jobs" is duplicated — são dois arquivos. O segundo era de conceito: o workflow reutilizável só fazia checkout, instalava o Python e as dependências, e parava. Um workflow chamado roda em jobs próprios, em máquinas próprias, e nada do que ele instala chega aos jobs de quem o chamou. Um "setup" reutilizável desse jeito não serve para nada. Workflow reutilizável serve para um trabalho completo, como rodar a suíte de testes; para compartilhar uma sequência de steps dentro do mesmo job, a ferramenta é uma composite action, um action.yml com runs: using: composite, chamada com uses: ./.github/actions/setup.
Exemplo Completo: Pipeline de Produção
# .github/workflows/pipeline-completo.yml
name: Pipeline Completo
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
pull-requests: write
jobs:
# ── Análise de Código ──────────────────────────────
analise:
name: Análise Estática
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install ruff black mypy bandit
- run: ruff check src/ tests/ --output-format=github
- run: black --check src/ tests/
- run: mypy src/ --ignore-missing-imports --no-error-summary
- run: bandit -r src/ -ll -q
# ── Testes ────────────────────────────────────────
testes:
name: Testes e Cobertura
runs-on: ubuntu-latest
needs: analise
outputs:
coverage: ${{ steps.cov.outputs.coverage }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt -r requirements-dev.txt
- name: Executar testes
id: cov
run: |
pytest tests/unit/ \
--cov=src \
--cov-report=xml \
--cov-report=json \
--cov-report=html \
--cov-fail-under=85 \
-q
COBERTURA=$(python -c "import json; d=json.load(open('coverage.json')); print(f\"{d['totals']['percent_covered']:.1f}\")")
echo "coverage=$COBERTURA" >> $GITHUB_OUTPUT
- name: Comentar cobertura no PR
if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
message: |
## 📊 Cobertura de Testes
**${{ steps.cov.outputs.coverage }}%** de cobertura
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: htmlcov/
# ── Build Docker ──────────────────────────────────
build:
name: Build e Push Docker
runs-on: ubuntu-latest
needs: testes
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build e Push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ── Deploy Staging ────────────────────────────────
staging:
name: Deploy Staging
runs-on: ubuntu-latest
needs: build
environment:
name: staging
url: https://staging.escola.com
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.SSH_STAGING_KEY }}
script: |
cd /app/escola
docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
# "docker compose up" não tem -e: a variável vai no ambiente
IMAGE_TAG=${{ github.sha }} docker compose up -d --no-deps api
sleep 5
curl -f http://localhost:8000/health
- name: Notificar sucesso
if: success()
run: |
echo "✅ Deploy staging concluído: ${{ github.sha }}"
# ── Sumário ───────────────────────────────────────
sumario:
name: Sumário do Pipeline
runs-on: ubuntu-latest
needs: [analise, testes, build, staging]
if: always()
steps:
- name: Gerar sumário
run: |
echo "## 📋 Resultado do Pipeline" >> $GITHUB_STEP_SUMMARY
echo "| Etapa | Status |" >> $GITHUB_STEP_SUMMARY
echo "|--------------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Análise | ${{ needs.analise.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Testes | ${{ needs.testes.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Build Docker | ${{ needs.build.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Staging | ${{ needs.staging.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Cobertura | ${{ needs.testes.outputs.coverage }}% |" >> $GITHUB_STEP_SUMMARY
Boas Práticas de CI/CD
# 1. Fixe as versões das actions — o mais seguro é o SHA do commit
uses: actions/checkout@<SHA de 40 caracteres> # vX.Y.Z — imutável
uses: actions/checkout@v4 # tag: pode ser movida pelo dono
uses: actions/checkout@main # ✗ — muda a cada commit
# 2. Minimize permissões
permissions:
contents: read # só leitura por padrão
packages: write # só onde necessário
# 3. Use concurrency para evitar deploys simultâneos
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # não cancela deploys em andamento
# 4. Artefatos para debug
- uses: actions/upload-artifact@v4
if: failure() # só sobe se falhar
with:
name: logs-falha
path: |
logs/
*.log
retention-days: 7
# 5. Timeouts para evitar jobs travados
jobs:
testes:
timeout-minutes: 30
# 6. Condicionais claras
- name: Deploy produção
if: >
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
needs.testes.result == 'success'
Sobre a primeira regra: uma tag como v4 é só um ponteiro, e quem controla o repositório da action pode movê-la. Foi o que aconteceu em março de 2025 com a action tj-actions/changed-files: um invasor reapontou as tags para um commit malicioso que imprimia os secrets no log, e os repositórios que a usavam por tag passaram a rodar o código novo sem mudar uma linha. Fixar pelo SHA do commit, com a versão num comentário, torna a referência imutável, e o Dependabot, configurado com package-ecosystem: github-actions, continua abrindo PRs de atualização. As versões principais dos exemplos deste artigo envelheceram: em setembro de 2026, o actions/checkout e o actions/setup-python estão na v7, o actions/upload-artifact na v7 e o actions/cache na v6. Os workflows foram conferidos com o actionlint 1.7, que valida a sintaxe, as expressões ${{ }} e as entradas das actions mais conhecidas; vale rodá-lo localmente antes de cada push, porque um erro de YAML só aparece no GitHub depois do commit.
Com GitHub Actions, cada push dispara as mesmas verificações que um desenvolvedor cuidadoso faria à mão, só que sempre. O CI roda o linter, o verificador de tipos e os testes de Qualidade de Código: linting, type hints e mypy e de Testes com pytest numa matriz de versões e sistemas, com serviços como PostgreSQL e Redis subindo ao lado. O CD pega o que passou, constrói a imagem, implanta em staging, confere com um smoke test e, com aprovação, leva à produção. Secrets, environments, cache, workflows agendados e reutilizáveis completam o conjunto.
O ponto fraco de um pipeline é que ele também é código, só que um código que ninguém testa. Os defeitos desta página mostram isso: um deploy que não esperava o CI, uma opção de linha de comando que não existe, um environment num lugar onde o GitHub não o aceita, um workflow de setup cujo resultado se perdia ao terminar. Três hábitos resolvem a maior parte: rodar o actionlint antes do push, fixar as actions pelo SHA do commit e testar um workflow novo num branch antes de confiar nele para a produção.
Fontes e leituras recomendadas
- GitHub Actions — documentação oficial — https://docs.github.com/en/actions
- Marketplace de Actions — https://github.com/marketplace?type=actions
- actions/setup-python — https://github.com/actions/setup-python
- docker/build-push-action — https://github.com/docker/build-push-action
- Codecov — cobertura de testes — https://about.codecov.io/
- pip-audit — auditoria de dependências — https://github.com/pypa/pip-audit
- HUMBLE, Jez; FARLEY, David. Continuous Delivery. Addison-Wesley, 2010. — a referência original sobre entrega contínua de software.
- KIM, Gene et al. The DevOps Handbook. 2. ed. IT Revolution, 2021. — fundamentos e práticas de DevOps aplicadas a organizações reais.
Exercícios
Exercício 1
O cd.yml da primeira versão deste artigo tinha os gatilhos push na main e workflow_run do CI, e o job de staging rodava com if: github.event_name == 'push' || github.event.workflow_run.conclusion == 'success'. Um desenvolvedor faz push de um commit que quebra três testes. O CI fica vermelho. O que aconteceu com o staging?
Ver resposta
✓ Resposta: Foi atualizado com o código quebrado. O push disparou dois workflows ao mesmo tempo, o CI e o CD, e no CD a condição github.event_name == 'push' já era verdadeira: o deploy começou em paralelo com os testes, sem esperar o resultado. Quando o CI terminou em falha, o workflow_run disparou o CD de novo, e essa segunda execução foi corretamente barrada pela conclusion == 'success', mas o estrago já estava feito. Num commit que passa, o problema aparece como dois deploys seguidos. A correção é ter um único caminho para a main: o workflow_run, que só existe depois de o CI terminar. O push fica restrito às tags de versão. Outra opção é juntar CI e CD num workflow só, com o job de deploy dependendo dos testes via needs, que é o que o pipeline completo deste artigo faz.
Exercício 2
Para usar os secrets do ambiente de staging num único passo, alguém escreve environment: staging dentro de um step, ao lado do env: e do run:. O actionlint responde com unexpected key "environment" for step to run shell command. Por que o GitHub não aceita isso no step, e como fica o workflow certo?
Ver resposta
✓ Resposta: Porque o environment é uma unidade de controle, e ela vale para o job inteiro. Um environment pode exigir aprovação de revisores, restringir quais branches implantam nele e esperar um tempo antes de liberar; essas regras decidem se o job começa. Não faz sentido um job já em execução pausar no meio para pedir aprovação de um step. Os secrets do environment só ficam disponíveis a partir do momento em que o job é liberado. O certo é um job próprio com environment: staging no nível do job, contendo os steps que precisam daqueles secrets. Se outros steps não precisam do ambiente, eles ficam em outro job, ligado pelo needs. O efeito colateral é bom: o que roda com as credenciais de produção fica isolado e visível.
Exercício 3
O deploy do pipeline completo falhava no servidor com unknown shorthand flag: 'e' in -e. A linha era docker compose up -d --no-deps -e IMAGE_TAG=${{ github.sha }} api. O que está errado, e como passar a tag da imagem?
Ver resposta
✓ Resposta: O docker compose up não tem a opção -e. Ela existe em docker run, docker compose run e docker compose exec, que criam ou entram num contêiner avulso; o up sobe os serviços como estão descritos no compose.yaml. Medido com o Docker Compose 2.40.3, a mensagem é exatamente a do enunciado. Para parametrizar a imagem, o arquivo do compose usa interpolação, image: ghcr.io/escola/api:${IMAGE_TAG}, e a variável vem do ambiente do shell: IMAGE_TAG=abc123 docker compose up -d --no-deps api. Também serve um arquivo .env ao lado do compose, ou --env-file. Vale notar que o erro só apareceu no servidor, depois de o build e o push terem passado. Um passo de deploy também merece ser testado, nem que seja com o docker compose config, que mostra o arquivo com as variáveis já resolvidas.
Exercício 4
Uma equipe cria o _setup-python.yml reutilizável, que faz checkout, instala o Python e as dependências, e o chama no começo do CI. O job seguinte roda pytest e falha com pytest: command not found. "Mas o setup acabou de instalar", diz alguém. O que aconteceu?
Ver resposta
✓ Resposta: Cada job roda numa máquina virtual nova. O workflow reutilizável executou os próprios jobs, instalou tudo na máquina dele, terminou, e a máquina foi descartada. O job seguinte começou do zero, sem o código, sem o Python configurado e sem o pytest. Nada passa de um job para outro a não ser o que é passado de propósito: outputs, para valores pequenos, e artefatos (upload-artifact e download-artifact), para arquivos. Os passos de preparação precisam estar dentro do mesmo job que os usa. Para reaproveitá-los em vários jobs sem copiar o YAML, a ferramenta é uma composite action, que injeta os steps no job que a chama. O workflow reutilizável é para um trabalho completo, como "rodar a suíte de testes com esta versão do Python". E a primeira versão do exemplo nem chegaria a rodar: definição e uso estavam num arquivo só, com duas chaves jobs:, que o actionlint recusa.
Exercício 5
O relatório diário, agendado com cron: "0 6 * * 1-5" e o comentário "6h, seg a sex", chega às 3h da manhã, horário de Brasília, e na segunda-feira ninguém recebe o de sexta à noite. Uma pessoa da equipe sugere trocar para "0 9 * * 1-5". Isso resolve? Que outros cuidados um workflow agendado exige?
Ver resposta
✓ Resposta: Resolve o horário. O schedule do GitHub Actions interpreta o cron sempre em UTC, e Brasília está em UTC−3 o ano todo, sem horário de verão desde 2019. Portanto "0 9 * * 1-5" é 6h em Brasília. Numa equipe com horário de verão, a hora local muda duas vezes por ano, e o cron em UTC não acompanha. Outros cuidados: o GitHub não garante o minuto exato, e em horários de pico, como a virada da hora, o disparo pode atrasar ou até ser descartado. Por isso é melhor evitar o minuto 0 para tarefas importantes. Em repositórios públicos sem atividade por 60 dias, os workflows agendados são desativados automaticamente. E o workflow agendado roda sempre na versão do arquivo que está na branch padrão, então uma correção feita num branch não vale até o merge. Para tarefas críticas, como o backup, vale somar um alerta de "não rodou", porque a ausência de execução não gera erro em lugar nenhum.