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.jsonounpm-shrinkwrap.json; - se o lock não corresponder ao
package.json, para com erro em vez de atualizar; - remove o
node_modulesexistente antes de instalar; - nunca escreve no
package.jsonnem 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
- Versione o lockfile. O
package-lock.jsonvai para o Git, e nunca para o.gitignore. - Use
npm cino CI. Em todo job que instala dependências. - Use
npm cino Dockerfile, copiando o lock junto com opackage.jsonantes da instalação. - Atualize em commits próprios, com mensagem dizendo o que subiu e por quê.
- Revise o diff do lock em toda mudança, para ver quantos pacotes mudaram.
- 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-lockfilena versão 1 eyarn install --immutablenas versões mais novas. - pnpm:
pnpm install --frozen-lockfile. - Composer (PHP):
composer installrespeita ocomposer.lock;composer updateé o comando que muda versões. - Cargo (Rust):
cargo build --lockedfalha se oCargo.lockprecisar mudar. - NuGet (.NET): lockfile e modo travado no restore, assunto do post sobre versão de pacote NuGet.
Erros comuns
- Rodar
npm installno 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.jsonpara a imagem: onpm cifalha por falta de lock ou, pior, alguém troca paranpm installpara "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.jsonestá 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?
Referência: https://docs.npmjs.com/cli/v10/commands/npm-ci/