Programação Por Equipe DevBras

Capa do artigo: npm ci e lockfile: a versão que você testou é a que vai para produção

npm ci e lockfile: a versão que você testou é a que vai para produção

Use o lockfile como contrato: CI e imagem instalam com npm ci, atualizar dependência vira um commit separado com teste, e conflito no lock se resolve sem apagar o arquivo.

Na sua máquina, os testes passam. No CI, falham com TypeError: formatar is not a function. Ninguém mexeu nesse código. O que mudou foi a versão da biblioteca: seu node_modules tinha a 2.3.1 instalada desde março, e o CI, rodando npm install num ambiente limpo, puxou a 2.6.0, que mudou a forma de exportar a função. O package-lock.json estava no repositório, apontando a versão certa. Só ninguém obedeceu a ele.

A resposta curta: em CI e na construção da imagem, instale com npm ci, que usa exatamente o que está no lockfile e falha se ele não bater com o package.json. Atualizar dependência é um ato separado: um commit que muda o lockfile, com os testes rodando junto. E nunca apague o lock para "resolver" um conflito sem olhar o que entrou.

Por que o package.json sozinho não fixa versão

Quando você roda npm install @loja/formata-preco, o npm grava no package.json algo como "@loja/formata-preco": "^2.3.1". O acento circunflexo significa "qualquer versão compatível a partir da 2.3.1", o que na prática aceita todas as 2.x maiores ou iguais a 2.3.1. É uma faixa, não uma versão.

Além disso, cada dependência sua tem as próprias dependências, também declaradas como faixas. Um projeto pequeno com 15 dependências diretas costuma ter centenas de pacotes instalados. Sem lock, cada instalação limpa resolve essa árvore de novo, com o que estiver publicado naquele dia.

O package-lock.json registra a árvore resolvida: a versão exata de cada pacote, de onde foi baixado e um hash de integridade. Ele é o que faz duas instalações em dias diferentes produzirem o mesmo node_modules.

A diferença entre npm install e npm ci

O npm install foi feito para o dia a dia do desenvolvimento. Ele usa o lock como referência, mas pode atualizá-lo: se o package.json pede algo que o lock não satisfaz, ele resolve de novo e reescreve o arquivo.

O npm ci foi feito para ambientes automatizados, e a documentação oficial descreve o comportamento com clareza:

  • exige um package-lock.json ou npm-shrinkwrap.json;
  • se o lock não corresponder ao package.json, para com erro em vez de atualizar;
  • remove o node_modules existente antes de instalar;
  • nunca escreve no package.json nem no lock.

Ou seja: com npm ci, ou você instala exatamente o que foi testado, ou o build quebra avisando. Não existe o meio-termo silencioso.

Como deixar o lock no comando

  1. Versione o lockfile. O package-lock.json vai para o Git, e nunca para o .gitignore.
  2. Use npm ci no CI. Em todo job que instala dependências.
  3. Use npm ci no Dockerfile, copiando o lock junto com o package.json antes da instalação.
  4. Atualize em commits próprios, com mensagem dizendo o que subiu e por quê.
  5. Revise o diff do lock em toda mudança, para ver quantos pacotes mudaram.
  6. Automatize as propostas de atualização com Dependabot ou Renovate, que abrem pull requests com o lock atualizado e deixam o CI decidir.

Dockerfile que respeita o lock

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]

Copiar os dois arquivos de manifesto antes do resto do código também aproveita o cache de camadas do Docker: enquanto as dependências não mudam, a instalação não roda de novo. Se você ainda está montando a primeira imagem, o guia de Docker no servidor cobre o restante, e o de usuário não root no container explica a linha USER node.

Job de CI

name: testes
on: [push, pull_request]
jobs:
  testes:
    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 test

Se alguém alterar o package.json e esquecer de commitar o lock atualizado, o npm ci falha nesse job, antes de qualquer deploy. É exatamente o aviso que você quer.

Atualizar de propósito, com teste junto

Voltando ao caso da biblioteca de preço: a atualização para a 2.6.0 pode ser desejável, só não pode acontecer por acidente. O fluxo deliberado fica assim:

$ npm outdated
Package              Current  Wanted  Latest  Location
@loja/formata-preco    2.3.1   2.6.0   3.0.2  node_modules/@loja/formata-preco

$ npm install @loja/formata-preco@2.6.0
$ git diff --stat package-lock.json
 package-lock.json | 14 +++++++-------
$ npm test

A coluna Wanted mostra a maior versão dentro da faixa do package.json; Latest é a mais recente publicada, que pode ser uma versão maior com mudanças incompatíveis. Se os testes quebrarem, você descobre no seu branch, com um diff de 14 linhas para investigar, e não no CI de outra pessoa. Corrija o uso da função, rode os testes de novo e commite package.json, lock e código juntos.

Conflito no lockfile sem apagar o arquivo

Dois branches que mexem em dependências quase sempre geram conflito no package-lock.json. A tentação é apagar o arquivo e rodar npm install. Isso resolve a árvore inteira de novo e pode atualizar dezenas de pacotes que nenhum dos dois branches pretendia mudar.

O caminho mais seguro: resolva primeiro os conflitos do package.json à mão, depois rode npm install para o npm refazer o lock a partir dos dois lados, e confira o git diff do lock antes de commitar. Se aparecerem mudanças em pacotes que nenhum branch tocou, investigue antes de seguir.

O mesmo princípio em outros ecossistemas

  • Yarn: yarn install --frozen-lockfile na versão 1 e yarn install --immutable nas versões mais novas.
  • pnpm: pnpm install --frozen-lockfile.
  • Composer (PHP): composer install respeita o composer.lock; composer update é o comando que muda versões.
  • Cargo (Rust): cargo build --locked falha se o Cargo.lock precisar mudar.
  • NuGet (.NET): lockfile e modo travado no restore, assunto do post sobre versão de pacote NuGet.

Erros comuns

  • Rodar npm install no CI ou no Dockerfile: o build pode resolver versões diferentes das testadas, e a diferença só aparece quando quebra.
  • Colocar o lock no .gitignore: cada máquina e cada build resolvem a árvore do zero, e "funciona aqui" vira rotina.
  • Apagar o lock para resolver conflito: dezenas de pacotes sobem de versão sem revisão, misturados num commit que dizia outra coisa.
  • Copiar só o package.json para a imagem: o npm ci falha por falta de lock ou, pior, alguém troca para npm install para "consertar".
  • Atualizar dependência junto com uma funcionalidade: quando algo quebra, ninguém sabe se foi o código novo ou a biblioteca.
  • Tratar latest como estratégia: a versão de produção passa a depender do dia do deploy, o mesmo problema da tag latest em imagens.

Checklist

  • O package-lock.json está versionado e atualizado.
  • Todos os jobs de CI instalam com npm ci.
  • O Dockerfile copia o lock e usa npm ci.
  • Atualizações de dependência chegam em commits ou pull requests próprios.
  • Toda mudança no lock passa pelos testes antes do merge.
  • Conflitos no lock são resolvidos com o npm, seguidos de revisão do diff.
  • Há um processo (Dependabot, Renovate ou rotina manual) para propor atualizações.

Abra o Dockerfile e o arquivo de CI do seu projeto agora: a instalação usa npm ci ou um npm install que aceita versão nova sem avisar?

Avalie este artigo

Referência: https://docs.npmjs.com/cli/v10/commands/npm-ci/