Build em produção, CI e determinismo

TL;DR

Um build “que funcionou ontem” pode produzir um binário diferente hoje — por causa de timestamps, versões flutuantes de dependências ou variáveis de ambiente implícitas. Determinismo é a garantia de que código + inputs idênticos sempre geram o mesmo artefato. Em CI, isso se traduz em quatro camadas: (1) travar dependências com lockfile + npm ci/pnpm install --frozen-lockfile; (2) fazer cache inteligente por chave — sem cache ou com cache ruim, CI é o gargalo mais visível; (3) separar o que entra no bundle em build-time (Vite import.meta.env) do que entra em runtime, para build once, deploy many; (4) — a fronteira sênior — garantir hermeticidade (build sem acesso à rede) e provenance verificável (SLSA + SBOM) para artefatos que precisam de auditoria de supply chain. Source maps fecham o ciclo: sourcemap: 'hidden' + upload pro Sentry antes do deploy. Cache poisoning é o risco que o próprio cache introduz — restore-keys relaxa as garantias; para jobs críticos, instale do zero.


O problema que você ainda vai encontrar

Você faz um deploy numa sexta à noite. Tudo testa no CI. O job de build passa. Na segunda-feira, alguém reabre o mesmo branch depois de uma semana de férias, roda npm install localmente, e alguma coisa está quebrada — mas no CI passou porque o CI rodou na sexta.

Esse é o “funciona na minha máquina” evoluído: funciona no CI de terça, quebra no CI de segunda. Ou pior: funciona no build que você mandou pra staging, mas o build de produção que veio dez minutos depois é diferente porque uma dependência transitiva lançou um patch.

Esse problema tem um nome: não-determinismo de build. E tem solução — não perfeita, mas muito melhor do que não fazer nada.

Para entender o que está em jogo, pense assim: um build é uma função. Ela recebe código-fonte + dependências + configuração + ambiente e produz um artefato (bundle, container, executável). Se algum desses inputs varia entre duas execuções, o output varia. A questão é: quais inputs você controla, e quais você deixa escapar?


graph LR
    subgraph "Inputs controlados"
        S["Código-fonte\n(git commit)"]
        L["Lockfile\n(package-lock.json\npnpm-lock.yaml)"]
        C["Configuração\n(vite.config, tsconfig)"]
    end
    subgraph "Inputs que escapam sem cuidado"
        TS["Timestamp\n(new Date() no build)"]
        V["Versões flutuantes\n(sem lockfile)"]
        E["Env vars\nde ambiente local"]
        NV["Versão do Node\n(sem .nvmrc ou engines)"]
    end

    subgraph "Build"
        B["bun build / vite build\n/ tsc / webpack"]
    end

    S --> B
    L --> B
    C --> B
    TS -->|"❌ nondeterminism"| B
    V -->|"❌ nondeterminism"| B
    E -->|"⚠️ cuidado"| B
    NV -->|"⚠️ cuidado"| B

    B --> A["Artefato\n(bundle/container)"]

    style TS fill:#D0021B,color:#fff
    style V fill:#D0021B,color:#fff
    style E fill:#F5A623,color:#000
    style NV fill:#F5A623,color:#000

Lockfiles: a âncora do determinismo

O lockfile é o contrato de que toda instalação de dependências vai resultar exatamente nos mesmos pacotes — mesmas versões, mesmo grafo de resolução, mesmo hash de conteúdo. A nota 05 - Semver e o grafo de dependências cobre o mecanismo de resolução em detalhe; aqui o foco é o que fazer com o lockfile em CI.

A primeira regra é simples e frequentemente ignorada: o lockfile precisa estar no controle de versão. Todo projeto que tem um package.json deveria ter seu lockfile (package-lock.json, pnpm-lock.yaml ou bun.lockb) no git. Sem isso, cada npm install resolve as deps de novo — e pode pegar versões diferentes dependendo do dia.

A segunda regra é o npm ci:

# ❌ npm install em CI — pode atualizar o lockfile, resolve deps de novo
npm install
 
# ✅ npm ci em CI — instala exatamente o que está no lockfile, falha se divergir
npm ci
 
# ✅ pnpm equivalente
pnpm install --frozen-lockfile
 
# ✅ yarn equivalente
yarn install --immutable

O que npm ci faz de diferente:

  1. Remove node_modules antes de instalar — não há estado remanescente de installs anteriores.
  2. Não atualiza o lockfile — se package.json e package-lock.json divergirem, o comando falha com erro. Isso é feature, não bug: é sua rede de segurança contra “alguém rodou npm install localmente e não commitou o lockfile atualizado”.
  3. Pula a resolução de versão — como o lockfile já tem as versões resolvidas, o npm só precisa baixar e instalar. Em ambientes com cache quente, é 2–3× mais rápido que npm install.

O .npmrc pode reforçar esse comportamento para toda a equipe:

# .npmrc — commite esse arquivo junto com o package.json
# Garante que npm ci é usado por todas as ferramentas que leem este arquivo
 
# Para npm ≥ 7: exige lockfile version 2 (compatível com npm 7+)
lockfile-version=3
 
# engine-strict: falha se a versão do Node não bater com o campo "engines" do package.json
engine-strict=true
// package.json — cravar a versão do Node esperada
{
  "engines": {
    "node": ">=22.0.0"
  }
}

Cache de build em CI: o gargalo mais caro

Sem cache, cada push ao repositório dispara: baixar todas as dependências do zero + compilar/transpilar todo o código. Em um projeto médio com 500 dependências e 50 mil linhas de TypeScript, isso pode levar 4–8 minutos de CI para cada PR. Multiplique por 20 devs fazendo 10 pushes por dia e você tem um gargalo sério.

A solução é cache em camadas. No GitHub Actions, a action actions/setup-node tem cache integrado — mas entender o que ela está cacheando (e o que não está) faz toda a diferença.

# .github/workflows/build.yml — pipeline completo com cache
name: Build e Testes
 
on:
  push:
    branches: [main, develop]
  pull_request:
 
jobs:
  build:
    runs-on: ubuntu-latest
 
    steps:
      # 1. Checkout do código
      - name: Checkout
        uses: actions/checkout@v4
 
      # 2. Setup do Node com cache de dependências integrado
      #    O `cache: 'npm'` usa package-lock.json como cache key.
      #    Se o lockfile não mudou, restaura node_modules do cache.
      #    Suporte: 'npm', 'yarn', 'pnpm'
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
          # Para pnpm: cache: 'pnpm'
          # Para yarn: cache: 'yarn'
 
      # 3. Instala com frozen lockfile — determinístico
      - name: Instalar dependências
        run: npm ci
 
      # 4. Build — com cache do output de build
      #    Usa hash dos arquivos de src como chave de cache.
      #    Se src/ não mudou, restaura dist/ do cache.
      - name: Cache de build output
        uses: actions/cache@v4
        with:
          path: dist/
          # Cache key = SO + hash de todos os arquivos de source
          key: build-${{ runner.os }}-${{ hashFiles('src/**/*', 'vite.config.*', 'tsconfig.json') }}
          restore-keys: |
            build-${{ runner.os }}-
 
      # 5. Build condicional — só roda se o cache de build não foi restaurado
      - name: Build
        run: npm run build
 
      # 6. Testes
      - name: Testes
        run: npm test
 
      # 7. Upload do artefato — disponibiliza o bundle para jobs posteriores
      #    (ex: job de deploy que roda depois)
      - name: Upload artefato de build
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
          retention-days: 7

O que actions/setup-node cacheia, exatamente?

O setup-node com cache: 'npm' cacheia o store global do npm (o cache de download de pacotes — em ~/.npm), não o node_modules do projeto. Isso significa que npm ci ainda vai reconstruir node_modules a cada job, mas não vai baixar os arquivos .tgz de novo — apenas os extrai do cache local. Para pnpm, o mesmo vale: ele cacheia o pnpm store (~/.local/share/pnpm/store). Isso é mais eficiente que cachear node_modules diretamente porque o store é compartilhado entre versões do Node.

A chave de cache certa importa

O segredo do cache eficiente está na key. Se a key é muito ampla (ex: só o nome do branch), o cache nunca invalida e você pode restaurar um cache stale. Se é muito estreita (ex: hash de todo o repositório), o cache nunca reutiliza e você pagou pelo armazenamento sem ganho.

A lógica ideal para node_modules:

# Chave primária: SO + hash do lockfile
# O lockfile muda só quando deps mudam — exatamente quando o cache deve invalidar
key: deps-${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
 
# Fallback: se não tem cache exato, restaura o mais recente
restore-keys: |
  deps-${{ runner.os }}-node22-
  deps-${{ runner.os }}-

Para output de build (a pasta dist/):

# Chave: hash de source + configurações que afetam o output
key: build-${{ runner.os }}-${{ hashFiles('src/**', 'public/**', 'vite.config.*', 'tsconfig.json', 'package.json') }}

sequenceDiagram
    participant GH as GitHub Actions Runner
    participant C as Cache Store
    participant NPM as npm Registry

    GH->>C: Restaurar cache deps (key: lockfile hash)?
    alt Cache hit (lockfile não mudou)
        C-->>GH: ✅ node_modules restaurado do cache
        GH->>GH: npm ci (extrai do ~/.npm, não baixa)
    else Cache miss (lockfile mudou)
        C-->>GH: ❌ Cache não encontrado
        GH->>NPM: npm ci (baixa pacotes novos)
        GH->>C: Salvar novo cache (~/.npm)
    end

    GH->>C: Restaurar cache build (key: src hash)?
    alt Cache hit (src não mudou)
        C-->>GH: ✅ dist/ restaurado do cache
        note over GH: Pula o passo de build!
    else Cache miss (src mudou)
        C-->>GH: ❌ Cache não encontrado
        GH->>GH: npm run build
        GH->>C: Salvar novo cache (dist/)
    end

Artefatos: build uma vez, use em vários jobs

Em pipelines mais complexos, você tem um job de build e vários jobs dependentes: um que roda testes de integração, um que faz análise de bundle, um que faz deploy em staging. O erro comum é cada job reconstruir o projeto do zero.

A solução é o padrão upload/download de artefato: o job de build produz o artefato e faz upload; os outros jobs baixam o artefato e usam diretamente.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npm run build
      # Artefato identificado pelo SHA do commit — garante rastreabilidade
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
 
  test-e2e:
    needs: build  # Só roda depois do build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist-${{ github.sha }}  # Baixa o mesmo artefato
          path: dist/
      - run: npm run test:e2e  # Usa o dist/ que veio do build
 
  deploy-staging:
    needs: [build, test-e2e]  # Só depois do build E dos testes
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
      - run: ./scripts/deploy.sh staging

Esse padrão garante que todos os jobs usam exatamente o mesmo artefato — não uma reconstrução que pode ter saído diferente.


Build once, deploy many: o dilema de variáveis de ambiente

Aqui está uma das tensões mais práticas do build frontend moderno: o Vite (e outros bundlers) substitui import.meta.env.VITE_* em tempo de build, não em runtime. Isso significa que o valor de VITE_API_URL fica literalmente gravado no bundle gerado.

// src/api.ts — código-fonte
const baseUrl = import.meta.env.VITE_API_URL;
 
// dist/assets/index-abc123.js — após o build com VITE_API_URL=https://api.staging.com
const baseUrl = "https://api.staging.com"; // hardcoded no bundle!

O problema: se você quer fazer um único build que sirva staging, produção e qualquer outro ambiente, você não pode ter a URL de API gravada no bundle em tempo de build. Você precisaria recompilar para cada ambiente — o que derrota o propósito de builds determinísticos e lentos.

Há três abordagens, cada uma com trade-offs:


flowchart TD
    subgraph "Abordagem 1: Build por ambiente (simples, mas ineficiente)"
        BA1["npm run build\n(VITE_API_URL=staging)"]
        BA2["npm run build\n(VITE_API_URL=prod)"]
        BA1 --> BU1["bundle-staging.js"]
        BA2 --> BU2["bundle-prod.js"]
    end

    subgraph "Abordagem 2: Runtime config via window.env (build once)"
        BC["npm run build\n(sem VITE_ vars)"]
        BC --> BU3["bundle.js\n(window.env.VITE_API_URL)"]
        BU3 --> ENV["envsubst\nno container startup"]
        ENV --> STA["index.staging.html\n(window.env = {VITE_API_URL: 'staging'})"]
        ENV --> PROD["index.prod.html\n(window.env = {VITE_API_URL: 'prod'})"]
    end

    subgraph "Abordagem 3: API proxied no servidor (SSR/BFF)"
        BS["build sem URL\nde API exposta"]
        BS --> BU4["bundle.js\n(chama /api/*)"]
        BU4 --> SRV["Servidor Node/nginx\nproxia para API real"]
    end

    style BA1 fill:#F5A623,color:#000
    style BA2 fill:#F5A623,color:#000
    style BC fill:#4A90D9,color:#fff
    style BS fill:#4A90D9,color:#fff

Abordagem 1 — Build por ambiente: a mais simples. Você cria um build separado para cada ambiente, usando variáveis VITE_* diferentes. Funciona, é fácil de entender, mas você perde o benefício de “testar exatamente o que vai pra produção” — staging testou um bundle diferente do que vai pra prod.

Abordagem 2 — window.env via envsubst: o bundle usa window.env.VITE_API_URL em vez de o valor hardcoded. O index.html tem um placeholder "${VITE_API_URL}". No startup do container, um script roda envsubst e substitui o placeholder pelo valor real da variável de ambiente do container. Você tem um único bundle; só o HTML muda por ambiente. Esta é a abordagem build once, deploy many de verdade.

O mecanismo que torna isso possível é um plugin Vite — como o vite-plugin-runtime-env. O plugin intercepta o build e faz duas coisas: (1) reescreve automaticamente todas as ocorrências de import.meta.env.VITE_* no bundle gerado para window.env.VITE_*; (2) injeta no index.html um bloco <script> com os placeholders que o envsubst vai substituir. O código TypeScript que você escreve não muda — você continua usando import.meta.env.VITE_API_URL normalmente; o plugin faz a reescrita durante o bundle. O resultado é que o bundle é estático e cacheável, e apenas o HTML varia por ambiente.

// vite.config.ts — habilitando runtime env
import { defineConfig } from 'vite';
import runtimeEnv from 'vite-plugin-runtime-env';
 
export default defineConfig({
  plugins: [runtimeEnv()],
  // Não inclua VITE_API_URL nas env vars de build — deixe vazio ou use placeholder
});
<!-- index.html gerado pelo plugin — placeholder para envsubst -->
<script>
  window.env = {
    VITE_API_URL: "${VITE_API_URL}"  // substituído no startup do container
  };
</script>
# entrypoint.sh do container — roda antes de nginx/node servir o app
envsubst < /usr/share/nginx/html/index.html > /tmp/index.html
cp /tmp/index.html /usr/share/nginx/html/index.html

Abordagem 3 — Sem variáveis de API no cliente: o frontend só chama /api/* (relativo ao próprio domínio); um servidor na borda (nginx, BFF Node) proxia para a API real. Nenhuma URL de API fica no bundle. Mais robusto, mais complexo.

O que NÃO colocar em variáveis VITE_*

Todo valor prefixado com VITE_ é injetado no bundle e fica visível no source do cliente — qualquer pessoa pode ver via DevTools ou no bundle minificado. Nunca coloque: API keys privadas, client secrets de OAuth, tokens de serviço, senhas de banco. Essas coisas vivem em variáveis sem o prefixo VITE_ (que o Vite não expõe) ou — melhor ainda — nunca chegam ao cliente.

Build-time vs runtime: a distinção que importa

VariávelOnde viveQuem pode verExemplo
VITE_API_URLBundle (gravada em build)Todo usuário finalhttps://api.prod.com
VITE_FEATURE_FLAGBundleTodo usuário final"true"
DATABASE_URLServidor (nunca no bundle)Só o processo Nodepostgres://...
STRIPE_SECRET_KEYServidorSó o processo Nodesk_live_...
PORTRuntime do servidorSó o processo3000

Por que determinismo importa além do “funciona na minha máquina”

Há três razões mais profundas pelas quais determinismo é importante, e todas aparecem em entrevistas sênior:

1. Cache hits dependem de determinismo. O sistema de cache de CI usa hashes de artefatos para decidir se precisa reconstruir. Se dois builds do mesmo commit geram artefatos diferentes (por causa de timestamps ou algum não-determinismo), o cache nunca funciona — cada job reconstrói tudo. Empresas com CI pesado perdem horas de máquina por semana por builds não-determinísticos.

2. Debugging de produção depende de determinismo. Quando um erro acontece em produção e você precisa investigar, você precisa saber exatamente qual artefato está rodando. Se builds não são determinísticos, você não tem garantia de que re-criar o build de dois dias atrás vai produzir o mesmo bundle que está em prod agora. Source maps (que veremos a seguir) só funcionam corretamente se o bundle em prod e o bundle de onde os source maps foram gerados são o mesmo.

3. Segurança de supply chain. Um build não-determinístico é mais difícil de auditar. Se você não consegue re-criar exatamente o artefato que está em produção a partir do código-fonte, você tem menos garantias sobre o que está rodando. A iniciativa reproducible-builds.org existe exatamente por isso — e ferramentas como o OSS Rebuild do Google (lançado em 2025) verificam builds de pacotes npm contra re-criações independentes.

SOURCE_DATE_EPOCH: padronizando timestamps

Uma das fontes mais comuns de não-determinismo é timestamps de arquivo. Ferramentas como esbuild, webpack e alguns geradores de documentação incluem timestamps no output por padrão.

A solução é a variável de ambiente SOURCE_DATE_EPOCH — um padrão da iniciativa reproducible-builds.org que instrui ferramentas compatíveis a usar um timestamp fixo (o timestamp do último commit git, por exemplo) em vez do Date.now() do momento do build:

# Define SOURCE_DATE_EPOCH como o timestamp do último commit git
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
 
# Ou um valor fixo para máximo determinismo
export SOURCE_DATE_EPOCH=1700000000
 
npm run build  # ferramentas compatíveis usam esse timestamp
# Em GitHub Actions
- name: Build determinístico
  env:
    SOURCE_DATE_EPOCH: ${{ github.event.head_commit.timestamp }}
  run: npm run build

Nem todas as ferramentas do ecossistema JS respeitam SOURCE_DATE_EPOCH ainda (o esbuild não tem suporte nativo; webpack e alguns plugins sim). Mas é a direção certa — e sinaliza que você pensa em builds de forma séria.


Hermetic builds: isolamento total como propriedade de segurança

“Determinístico” e “hermético” são frequentemente confundidos em entrevistas — e a distinção importa.

Determinístico significa: mesmos inputs → mesmo output. Você pode ter um build determinístico que ainda acessa a rede durante a execução (baixa uma tool, consulta uma API de licenças) — desde que o resultado seja sempre idêntico.

Hermético significa: o build não acessa nenhum recurso externo durante sua execução. Ele opera em sandbox total, com todas as dependências provisionadas antes do início. Se qualquer coisa está faltando, o build falha — não improvisa.


graph TD
    subgraph "Build não-hermético (típico de npm run build)"
        NB["Build inicia"] --> NPM_REG["❌ npm install acessa registry"]
        NB --> CURL_TOOL["❌ script baixa ferramenta via curl"]
        NB --> API["❌ script checa versão via API"]
        NPM_REG --> OUT1["Output (talvez diferente)"]
        CURL_TOOL --> Out1
        API --> Out1
    end

    subgraph "Build hermético (ex: Bazel, Nix, Docker multi-stage com COPY pre-fetched)"
        HB["Build inicia\n(sandbox sem rede)"] --> PRE["Deps pré-provisionadas\n(node_modules snapshot,\nbinários pinados)"]
        PRE --> OUT2["Output garantidamente idêntico\n(hash verificável)"]
    end

    style NPM_REG fill:#D0021B,color:#fff
    style CURL_TOOL fill:#D0021B,color:#fff
    style API fill:#D0021B,color:#fff
    style HB fill:#1a6b1a,color:#fff
    style OUT2 fill:#1a6b1a,color:#fff

Por que hermeticidade é importante para segurança — não só para determinismo

Um build que acessa a rede pode ser interceptado. Um atacante com acesso ao DNS da rede do runner, ou que comprometeu um CDN de ferramentas, pode substituir o que seu build baixa. Um build hermético elimina esse vetor — se o conteúdo está em sandbox, não há rede para interceptar.

O projeto Bazel do Google, adotado em larga escala para builds C++/Java, foi projetado com hermeticidade como propriedade central. Para o ecossistema JS, a abordagem mais próxima é um Dockerfile multi-stage onde a fase de instalação de dependências é executada em uma camada separada e o resultado (node_modules snapshottado) é copiado para a fase de build — sem acesso à internet nesta segunda fase:

Multi-stage não garante isolamento de rede por padrão — precisa de --network=none

Por padrão, o Docker não bloqueia acesso à rede em nenhum stage. Omitir npm install no Stage 2 é uma convenção arquitetural, não um enforcement. Se qualquer script do Stage 2 fizer curl ou wget, vai funcionar — e você não vai saber. Para isolamento real, passe --network=none no comando de build, ou por stage via RUN --network=none:

# Isola o build inteiro da rede
docker build --network=none -t meu-app .
 
# Ou por stage (BuildKit) — isola só o Stage 2
# No Dockerfile: RUN --network=none npm run build

No GitHub Actions, o runner tem acesso à internet por padrão; adicionar --network=none ao step de docker build é o que transforma a convenção em garantia. Aceite que para o típico deploy de SPA em CDN, a convenção é suficiente — o risco é baixo quando o Stage 2 genuinamente não precisa de rede. Para artefatos de produção crítica com requisitos de auditoria, --network=none é o enforcement correto.

# Dockerfile multi-stage com build hermético
# Stage 1: dependências (acessa rede, mas isolado e cacheável por layer)
FROM node:22.13.0-alpine3.21@sha256:abc123... AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts  # instala deps; --ignore-scripts reduz surface de ataque
 
# Stage 2: build (sem acesso à rede — usa deps do stage anterior)
FROM node:22.13.0-alpine3.21@sha256:abc123... AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules  # copia do stage deps
COPY . .
RUN npm run build  # sem npm install aqui — tudo que o build precisa já está em /app
 
# Stage 3: runtime (imagem mínima)
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

A nota 03 - Package managers - npm, pnpm, yarn e Bun cobre --ignore-scripts em detalhe: é um dos vetores mais ignorados em auditorias — scripts de install rodam código arbitrário no seu runner.

Trade-offs de hermeticidade

Build tradicional (npm run build)Build hermético (multi-stage)
Velocidade de setupRápido — npm ci resolve a partir do cacheMais lento — Docker build + layer caching
SegurançaModerada — depende de lockfileAlta — superficie de ataque eliminada
ComplexidadeBaixaMédia-alta (Dockerfile, layer strategy)
ReprodutibilidadeBoa (com lockfile)Excelente (layers são conteúdo-addressable)
Adequado paraApps web, bibliotecasProdução crítica, imagens de container

Provenance, SBOM e SLSA: a fronteira sênior de 2025–2026

Esta é a área que separa quem “faz CI” de quem “pensa em supply chain como propriedade de segurança”. O tema se tornou central depois dos ataques à SolarWinds (2020) e XZ Utils (2024), que mostraram que o artefato compilado pode ser diferente do que o código-fonte sugere.

O que é SLSA (Supply-chain Levels for Software Artifacts)

SLSA (pronunciado “salsa”) é um framework de segurança criado pelo Google e formalizado pela OpenSSF (Open Source Security Foundation) que define quatro níveis de garantia sobre como um artefato foi produzido. Não é uma ferramenta — é uma especificação de requisitos que ferramentas implementam.

SLSA Nível 1: Provenance existe e está documentada (automatizada)
SLSA Nível 2: Provenance é gerada pelo serviço de build (não pelo dev)
SLSA Nível 3: Build roda em ambiente isolado; fonte verificável
SLSA Nível 4: Build hermético; dois builds independentes produzem o mesmo hash

Para JavaScript, o que isso significa na prática:

  • Nível 1: GitHub Actions gera automaticamente um attestation assinado quando você usa actions/attest-build-provenance. O attestation diz: “este artefato foi produzido neste workflow, a partir deste commit, nesta hora.”
  • Nível 2: O runner do GitHub (não o seu código) assina o attestation com a chave do GitHub. Você não pode forjar isso localmente.
  • Nível 3: O job roda em um runner efêmero, sem estado anterior, e o código-fonte foi verificado via actions/checkout com commit SHA pinado.
# GitHub Actions — gerando provenance SLSA Level 2 automaticamente
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # necessário para assinar o attestation
      contents: read
      attestations: write
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
 
      - run: npm ci && npm run build
 
      # Gera o attestation de provenance assinado pelo GitHub
      - name: Gerar provenance attestation
        uses: actions/attest-build-provenance@v2
        with:
          subject-path: dist/  # o artefato a ser atestado
          # Gera um attestation no formato SLSA que qualquer um pode verificar via:
          # gh attestation verify dist/index.js --repo minha-org/meu-repo

O attestation gerado é um JSON assinado (usando Sigstore/cosign por baixo) que qualquer pessoa pode verificar independentemente. Isso é o que significa “provenance verificável” na prática.

O que é um SBOM (Software Bill of Materials)

SBOM é o inventário completo de tudo que compõe seu artefato: cada biblioteca, versão, licença e hash de conteúdo. Pense em um SBOM como o package-lock.json, mas mais formal, padronizado e incluindo dependências transitivas que não aparecem no package.json diretamente.

Os formatos padrão de SBOM são:

  • SPDX — padrão ISO/IEC 5962:2021, suportado por ferramentas de compliance e governos
  • CycloneDX — padrão OWASP, mais voltado para security analysis
# Gerando SBOM com syft (ferramenta open-source da Anchore)
# Instalar: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh
 
# SBOM do código-fonte (antes do build)
syft dir:. -o cyclonedx-json=sbom-source.json
 
# SBOM do container (depois do build)
syft your-org/your-image:latest -o spdx-json=sbom-container.json
 
# SBOM do node_modules diretamente (útil para auditar deps transitivas)
syft dir:node_modules -o cyclonedx-json=sbom-deps.json
# Integrado ao pipeline de CI
- name: Gerar SBOM
  run: |
    curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
    syft dir:dist -o cyclonedx-json=sbom.json
 
- name: Upload SBOM como artefato
  uses: actions/upload-artifact@v4
  with:
    name: sbom-${{ github.sha }}
    path: sbom.json
    retention-days: 90  # manter por 3 meses para auditoria

Por que SBOM está virando requisito em 2025–2026

O Executive Order 14028 dos EUA (2021) exige SBOMs de todos os fornecedores de software do governo federal americano. A EU Cyber Resilience Act (CRA), que entra em vigor progressivamente até 2027, vai impor requisitos similares para produtos digitais no mercado europeu. Para times que vendem software para clientes enterprise ou governamentais, gerar SBOM está deixando de ser “good practice” e virando requisito contratual. Fonte: CISA SBOM Resources e EU CRA overview, 2024

Content addressing: o que garante que o artefato é o que deveria ser

Content addressing é o mecanismo fundamental por trás de todo o resto: em vez de identificar arquivos por nome e localização, você os identifica pelo hash do seu conteúdo. Se o conteúdo mudar, o hash muda — e você sabe que algo foi alterado.

O package-lock.json já usa content addressing: cada entrada tem um campo integrity com o hash SHA-512 do tarball do pacote. Quando você roda npm ci, o npm verifica esse hash antes de usar o pacote — se o hash não bater, o install falha.

// Trecho de package-lock.json — content addressing em ação
{
  "node_modules/lodash": {
    "version": "4.17.21",
    "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
    "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
    //           ^^ SHA-512 do conteúdo do .tgz — qualquer alteração muda esse hash
  }
}

Para Docker, o equivalente é usar o digest da imagem em vez da tag:

# ❌ Tag é mutável — node:22-alpine pode apontar para conteúdos diferentes ao longo do tempo
FROM node:22-alpine
 
# ✅ Digest é imutável — este hash identifica exatamente esta imagem, para sempre
FROM node:22.13.0-alpine3.21@sha256:4a5b3c8d9e1f2a7b6c4d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

O digest pode ser obtido com:

docker pull node:22-alpine
docker inspect node:22-alpine --format='{{index .RepoDigests 0}}'
# node@sha256:4a5b3c...

Source maps em produção: a faca de dois gumes

Source maps resolvem um problema real: código minificado em produção é ilegível. Um stack trace como at f.t.n (index-a1b2c3.js:1:94821) não diz nada. Com source maps, o Sentry (ou qualquer error tracker) consegue mostrar a linha exata do seu TypeScript original.

O problema é que source maps são, literalmente, seu código-fonte completo — incluindo comentários, nomes de variáveis internas, lógica de negócio. Se você servir os .map publicamente, qualquer pessoa pode abrir o DevTools e ler seu fonte.

A solução é o padrão de hidden source maps:

// vite.config.ts — configuração de source maps para produção
import { defineConfig } from 'vite';
 
export default defineConfig({
  build: {
    // 'hidden': gera os .map mas NÃO adiciona o comentário
    // //# sourceMappingURL=index.js.map no bundle
    // Sem o comentário, o browser não tenta carregar o .map
    // O Sentry ainda consegue fazer a associação no servidor
    sourcemap: 'hidden',
 
    // Alternativas:
    // sourcemap: true    → gera .map E adiciona o comentário (expose público)
    // sourcemap: false   → não gera .map (não dá pra debugar prod)
    // sourcemap: 'inline' → embute o .map no bundle (bundle gigante, fonte exposto)
  }
});

sourcemap: true em produção expõe seu código-fonte

Com sourcemap: true, o bundle contém //# sourceMappingURL=index.js.map — e o browser vai tentar carregar esse arquivo. Se ele estiver público (e normalmente está, no mesmo servidor que serve o bundle), qualquer pessoa que abrir o DevTools consegue ler seu código TypeScript original, com nomes de variáveis, comentários e toda a lógica de negócio. Use sempre 'hidden' em produção.

Uploading para o Sentry

Com sourcemap: 'hidden', os arquivos .map existem localmente mas não ficam públicos. O passo seguinte é fazer upload deles pro Sentry antes de deletá-los:

# .github/workflows/deploy.yml — pipeline de build + upload de source maps
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
 
      - run: npm ci
 
      # Build com source maps hidden
      - name: Build
        run: npm run build
        env:
          VITE_API_URL: ${{ secrets.PROD_API_URL }}  # ← de GitHub Secrets, não hardcoded
 
      # Upload dos source maps para o Sentry
      # Precisa do SENTRY_AUTH_TOKEN em GitHub Secrets
      - name: Upload source maps para Sentry
        uses: getsentry/action-release@v1
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: minha-org
          SENTRY_PROJECT: meu-app
        with:
          environment: production
          sourcemaps: ./dist
 
      # Deleta os .map do dist ANTES do deploy
      # (alternativa ao hidden: true — garante que mesmo se o server servir tudo, .map não existe)
      - name: Remover source maps do artefato de deploy
        run: find ./dist -name "*.map" -delete
 
      # Deploy do dist/ — sem os .map
      - name: Deploy
        run: ./scripts/deploy.sh production

O fluxo completo em diagrama:


sequenceDiagram
    participant CI as CI Pipeline
    participant S as Sentry
    participant CDN as CDN / Servidor
    participant U as Usuário (browser)
    participant D as DevTools do Dev

    CI->>CI: vite build (sourcemap: 'hidden')
    note over CI: dist/index.js (SEM //# sourceMappingURL)<br/>dist/index.js.map (gerado, hidden)

    CI->>S: sentry-cli sourcemaps upload dist/
    S-->>CI: ✅ source maps indexados por release

    CI->>CI: find dist -name "*.map" -delete
    note over CI: dist/index.js (bundle limpo)<br/>dist/index.js.map (deletado)

    CI->>CDN: deploy dist/ (sem .map)

    U->>CDN: carrega index.js
    CDN-->>U: bundle minificado (sem sourceMappingURL)
    note over U: browser não tenta baixar .map<br/>nenhum 404 no console

    note over U: ❌ Erro em produção!
    U->>S: evento de erro (com stack trace minificado)
    S->>S: symbolica o stack trace<br/>usando os .map que foram uploaded
    S-->>D: stack trace LEGÍVEL<br/>(arquivo.ts, linha exata)


Matrizes de build: testando em múltiplas dimensões

Build matrix é o recurso do GitHub Actions que deixa você testar em combinações de Node.js × sistema operacional × flags de build em paralelo, sem duplicar o YAML.

jobs:
  test:
    name: "Testes Node ${{ matrix.node }} / ${{ matrix.os }}"
    runs-on: ${{ matrix.os }}
 
    strategy:
      # fail-fast: false → se Node 22 no Windows falha, Node 24 no Linux continua
      # Sem isso, o primeiro failure cancela todos os outros jobs
      fail-fast: false
 
      matrix:
        node: ['20', '22', '24']  # LTS atual, LTS anterior, next
        os: [ubuntu-latest, windows-latest, macos-latest]
 
        # Exclusões: Node 20 no macOS não é prioritário para este projeto
        exclude:
          - os: macos-latest
            node: '20'
 
        # Inclusões: um job extra com configuração especial
        include:
          - os: ubuntu-latest
            node: '22'
            coverage: true  # só esse job roda com cobertura
 
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - name: Testes
        run: |
          if [ "${{ matrix.coverage }}" = "true" ]; then
            npm run test:coverage
          else
            npm test
          fi

Na prática, matrix builds são mais úteis para bibliotecas (que precisam garantir compatibilidade com múltiplas versões do Node) do que para aplicações (onde você controla a versão em produção e pode testar só nela).

Dimensionando a matrix com cuidado

Uma matrix 3 nodes × 3 OS = 9 jobs paralelos. Para projetos com muitos deps de compilação (native addons, por exemplo), cada job pode levar 5–10 minutos. 9 × 10 min = 90 minutos de runner time por push. GitHub Actions cobra por minuto de runner (Windows e macOS têm multiplicadores). Uma matrix desnecessariamente grande pode ser 10× mais cara que o necessário.


Secrets no build: o risco que ninguém fala

Variáveis de ambiente de CI (SENTRY_AUTH_TOKEN, API keys de third parties) são secrets legítimos. O risco específico de build é diferente do risco de runtime: secrets que são passados como variáveis de ambiente no vite build podem acabar dentro do bundle se você não tomar cuidado.

O Vite só injeta no bundle variáveis prefixadas com VITE_. Mas há formas de vazar secrets mesmo assim:

// ❌ Isso não vaza — Vite não injeta process.env sem VITE_
const key = process.env.STRIPE_SECRET_KEY; // undefined no browser
 
// ❌ Mas isso pode vazar se você usou um plugin que injeta process.env completo
// (ex: webpack DefinePlugin mal configurado)
const key = process.env.STRIPE_SECRET_KEY; // "sk_live_..." no bundle!
 
// ✅ Correto — só prefixados com VITE_ são injetados
const publicKey = import.meta.env.VITE_STRIPE_PUBLIC_KEY; // ok, isso é público

Auditando o bundle por secrets vazados

Uma prática de segurança que vale implementar no CI: rodar grep no bundle final procurando por padrões de secrets conhecidos (prefixos de API keys, tokens longos). A tool trufflehog ou detect-secrets pode ser integrada como step de CI para alertar antes do deploy se um secret acabou no bundle. Ver 24 - Supply chain e segurança de dependências para a perspectiva mais ampla de supply chain.

# Step de auditoria de secrets no bundle
- name: Verificar secrets no bundle
  run: |
    # Verifica se algum padrão de secret comum vazou no bundle
    if grep -r "sk_live_\|sk_test_\|AKIA\|-----BEGIN" dist/ 2>/dev/null; then
      echo "❌ Possível secret encontrado no bundle!"
      exit 1
    fi
    echo "✅ Nenhum secret óbvio encontrado no bundle"

Cache poisoning: o risco que o cache introduz

Cache é um otimização poderosa — mas introduz um novo vetor de ataque: cache poisoning. A ideia é simples: se você consegue injetar conteúdo malicioso no cache de CI, o próximo job que restaurar esse cache vai executar código comprometido sem saber.

Isso não é teórico. Em 2023, um paper de pesquisa da ETH Zürich documentou vulnerabilidades de cache poisoning em GitHub Actions que permitiam que um atacante com acesso de write ao repositório (ou via fork + PR) injetasse conteúdo malicioso no cache compartilhado entre branches.

Como o cache poisoning funciona em GitHub Actions

O sistema de cache do GitHub Actions tem regras de acesso por branch:

Branch main pode ler caches de: main, branches que passaram por main
Branch de feature pode ler caches de: main (o branch base)
PR de fork NÃO pode ler caches do repositório original (proteção do GH)
PR interno (mesma org) PODE ler caches — esse é o vetor de risco

O ataque clássico:

  1. Atacante cria um branch interno e faz um PR
  2. PR roda CI, que restaura o cache de main (a restore-key encontra um cache compatível)
  3. O job modifica o node_modules cacheado (injetando um script malicioso em um pacote)
  4. O job salva o cache com uma nova chave que vai ser encontrada por main
  5. Próximos jobs de main restauram o cache envenenado

Mitigações práticas

# 1. Use chaves de cache com hash do lockfile — garante que só um lockfile idêntico restaura o cache
key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# A chave inclui o hash do lockfile; um atacante que mudou o lockfile tem uma chave diferente
 
# 2. Para trabalhos críticos de segurança, nunca restaure do cache — instale do zero
- name: Install (sem cache, para job de security audit)
  run: npm ci
  # sem actions/cache aqui — instala diretamente do registry
 
# 3. Verifique integridade após restaurar o cache
- name: Verificar integridade do cache de dependências
  run: npm ci --ignore-scripts  # re-verifica hashes mesmo com cache restaurado

O restore-keys é o vetor mais perigoso

A diretiva restore-keys existe para restaurar um cache “próximo o suficiente” quando a chave exata não existe. Mas ela relaxa as garantias: você pode restaurar um node_modules que corresponde a um lockfile diferente do atual. Para dependências de builds de produção, pense duas vezes antes de usar restore-keys — o risco de restaurar um estado inconsistente (ou envenenado) supera o benefício de velocidade.

Remote cache: o nível seguinte

Para monorepos e times maiores, o cache local do GitHub Actions tem um limite de 10GB por repositório e não é compartilhado entre repositórios diferentes. A solução é remote cache: um servidor de cache externo que armazena os outputs de build e é acessível por todos os runners.

O 21 - Monorepos - workspaces, Turborepo, Nx e changesets cobre Turborepo Remote Cache em detalhe. Mas o mecanismo é: Turborepo (ou Nx) faz hash de cada task (inputs + configuração), verifica se o hash existe no remote cache (self-hosted ou Vercel), e se sim, baixa o output diretamente em vez de executar a task.

# Turborepo com remote cache habilitado
TURBO_TOKEN=<token> TURBO_TEAM=<team> turbo build
# Se o cache hit: "cache hit, replaying output" — zero tempo de build
# Se o cache miss: executa, então faz upload do resultado pro cache remoto

A diferença de velocidade pode ser dramática: um monorepo com 50 packages que levaria 20 minutos para buildar pode levar 30 segundos se tudo estiver no remote cache. Mas a responsabilidade de segurança aumenta: o servidor de remote cache passa a ser um ponto crítico de confiança — um servidor comprometido pode servir outputs de build adulterados.


Armadilhas comuns

Armadilha 1: commitar node_modules em vez do lockfile

Parece óbvio, mas acontece especialmente em Docker ou projetos antigos. node_modules no git não garante determinismo (não é portátil entre SOs), aumenta massivamente o tamanho do repo, e não rastreia as versões exatas dos pacotes transitivos. A solução é gitignore node_modules e commitar o lockfile.

Armadilha 2: usar npm install em Dockerfile

Um Dockerfile de produção que usa npm install em vez de npm ci e não trava a versão do Node pode gerar imagens diferentes em builds diferentes — especialmente se você usa a tag node:22-alpine sem fixar o digest. Use npm ci, fixe a versão do Node (FROM node:22.13.0-alpine3.21 com digest), e inclua o lockfile no contexto de build.

Armadilha 3: cache de CI com chave muito ampla

Usar key: node_modules-${{ runner.os }} sem incluir o hash do lockfile faz o cache nunca invalidar — você pode estar instalando versões antigas de dependências mesmo depois de atualizar o lockfile. A chave sempre deve incluir ${{ hashFiles('**/package-lock.json') }} (ou o equivalente do seu package manager).

Armadilha 4: source maps inline em produção

sourcemap: 'inline' embute o source map no próprio bundle como um data URL base64. O bundle fica 3–5× maior, carrega mais lento, e expõe todo o código-fonte a qualquer pessoa que base64-decode o bundle. Nunca use 'inline' em produção.

Armadilha 5: VITE_ vars com valores diferentes entre ambientes de teste e produção

O problema mais sutil do build-time: você testou com VITE_API_URL=https://api.staging.com, o bundle passou em todos os testes, mas em produção o valor é diferente. O test não testou o que vai pra prod. A mitigação é o padrão de runtime config (Abordagem 2), ou pelo menos garantir que o pipeline de produção usa o mesmo build que o de staging — não reconstrói.


Como explicar em inglês

In a senior interview, you might be asked: “How do you ensure build reproducibility in your CI pipeline?”

A strong answer covers three layers. First, lockfile discipline: always commit package-lock.json or pnpm-lock.yaml, and use npm ci (or pnpm install --frozen-lockfile) in CI — never npm install. This ensures the exact same dependency graph is resolved every time. If package.json and the lockfile diverge, npm ci fails loudly, which is the correct behavior.

Second, layered CI caching: cache the package manager’s download store (not node_modules directly), keyed on the lockfile hash. Layer that with a build output cache keyed on source file hashes. This means you only rebuild when source actually changes — not on every push. Use actions/upload-artifact to share the same build artifact across jobs rather than rebuilding per job.

Third, build-time vs runtime environment separation: Vite bakes VITE_* variables into the bundle at build time. For a build once, deploy many pipeline, either use a runtime config pattern (rewriting import.meta.env.* to window.env.* and injecting values via envsubst at container startup), or accept that you’ll build once per environment. Sensitive values should never be VITE_* prefixed — they’d be visible in the browser.

For source maps: use sourcemap: 'hidden' in Vite so maps are generated but the //# sourceMappingURL comment is omitted. Upload maps to Sentry via sentry-cli before deleting them from the deploy artifact. This gives you readable stack traces in your error tracker without exposing source code publicly.

If you’re asked about supply chain security specifically: distinguish between deterministic (same inputs → same output) and hermetic (no external network access during build). A hermetic build, ideally using multi-stage Docker with pre-fetched dependencies, eliminates the network interception vector entirely. For artifacts that need to be audited — especially in enterprise or government contexts — SLSA Level 2 provenance attestations (generated automatically by actions/attest-build-provenance) and CycloneDX/SPDX SBOMs are becoming standard requirements under mandates like the US EO 14028 and EU Cyber Resilience Act.

On cache: understand that restore-keys relaxes the guarantee that the cache matches the current lockfile. For security-sensitive jobs, install from scratch rather than restoring cache. For monorepos, remote cache (Turborepo, Nx) shares build outputs across runners but creates a new trust boundary — a compromised cache server can serve tampered outputs.

PortuguêsInglês
build determinísticodeterministic build / reproducible build
build herméticohermetic build
lockfilelockfile (sem tradução)
cache de dependênciasdependency cache
envenenamento de cachecache poisoning
cache remotoremote cache
artefato de buildbuild artifact
variável de ambienteenvironment variable
build-time vs runtimebuild-time vs runtime
source map ocultohidden source map
upload de source mapsource map upload
matriz de buildbuild matrix
pipeline de CICI pipeline / CI workflow
secrets no buildbuild secrets / secrets leaking into bundle
hash de artefatoartifact hash / content hash
proveniência de buildbuild provenance
lista de materiais de softwareSoftware Bill of Materials (SBOM)
endereçamento por conteúdocontent addressing
atestaçãoattestation

Resumo em uma frase

Build determinístico em CI é a prática de garantir que código + lockfile + configuração idênticos sempre geram o mesmo artefato — e que esse artefato percorre todos os ambientes sem ser reconstruído, com cache inteligente por camada e source maps uploaded antes do deploy.


O que vem a seguir

Com o pipeline de build sólido — determinístico, hermético, com provenance verificável e artefatos auditáveis — a próxima camada de risco é o que você está colocando no bundle: as dependências em si. De onde vieram? São confiáveis? Há vulnerabilidades conhecidas? Alguém pode ter comprometido o pacote?


Fontes