Versionamento semântico (SemVer): o que os números de uma versão realmente dizem
SemVer transforma MAJOR.MINOR.PATCH num contrato de compatibilidade; entenda breaking changes, ranges, 0.x, pre-release e o inferno de dependências
Neste artigo
Toda biblioteca, todo pacote, todo serviço com API pública precisa anunciar suas mudanças de alguma forma, e a forma mais comum é um número de versão. O problema é que um número sozinho não diz nada. 2.4.1 para 2.5.0 — isso é seguro atualizar? E para 3.0.0? Sem uma convenção compartilhada, cada projeto responde essa pergunta do seu jeito, e atualizar uma dependência vira uma aposta: ou você lê o changelog inteiro linha por linha, ou atualiza e reza, ou trava a versão para sempre e acumula dívida técnica silenciosa. Versionamento semântico — SemVer — existe para tirar a aposta da equação, dando a cada número um significado fixo e previsível.
Vale entender SemVer a fundo mesmo que você nunca tenha publicado um pacote, porque toda dependência que seu projeto usa foi versionada por alguém que seguiu (ou não seguiu) essa convenção, e a confiança que você deposita num npm install ou num cargo update depende inteiramente de quão bem essa convenção foi respeitada do outro lado.
O problema que o SemVer resolve#
Antes de existir uma convenção, cada número de versão era um mistério particular. Um projeto podia subir de 1.2 para 1.3 numa mudança trivial e para 2.0 numa mudança igualmente trivial, só porque o mantenedor gostava de números redondos. Outro projeto fazia o oposto: acumulava anos de mudanças incompatíveis sob a mesma versão 1.x, porque nunca "sentiu que era hora" de virar a major. Não havia como, olhando só o número, prever o que ia acontecer ao atualizar.
Isso é o "dependency hell" em sua forma mais básica. Um projeto médio depende de dezenas de bibliotecas diretas e, transitivamente, de centenas. Se cada uma versiona à sua maneira, manter tudo atualizado exige ler o changelog de cada uma, a cada atualização, indefinidamente — um custo que cresce junto com o grafo de dependências e que, na prática, ninguém paga por completo. O resultado real é o oposto do desejável: times travam as versões, o projeto para de atualizar, e quando finalmente precisa atualizar (por uma vulnerabilidade de segurança, por exemplo), o salto é tão grande que vira um projeto à parte.
SemVer ataca o problema dando significado ao número, não à mudança em si. A ideia central, proposta por Tom Preston-Werner e adotada como padrão de fato pelo ecossistema de gerenciadores de pacotes modernos, é simples: o número de versão por si só deve comunicar o nível de risco de uma atualização, sem que ninguém precise ler uma linha de changelog para saber se é seguro atualizar automaticamente ou não. Isso não elimina o trabalho de verificação — trata-se de uma convenção seguida por humanos, não de uma garantia matemática —, mas transforma um mistério individual em um contrato compartilhado, e é justamente isso que faz automação (bots de atualização, ranges em gerenciadores de pacote, CI que testa múltiplas versões) ser viável em escala.
O contrato: MAJOR.MINOR.PATCH#
A forma é MAJOR.MINOR.PATCH, e cada posição tem uma regra fixa. Dado 2.5.3:
`` 2.5.3 │ │ └── PATCH: correção de bug retrocompatível │ └──── MINOR: funcionalidade nova, retrocompatível └────── MAJOR: quebra de compatibilidade (breaking change) ``
PATCH sobe quando você corrige um defeito sem mudar o comportamento esperado da API. Um bug que fazia uma função devolver o resultado errado em um caso de borda, corrigido para devolver o resultado certo, é um PATCH — mesmo que, tecnicamente, alguém pudesse estar dependendo do comportamento errado (esse é um caso-limite ao qual voltamos adiante). A regra prática é: quem usava a API corretamente, segundo a documentação, continua funcionando exatamente igual depois do PATCH; só o defeito desaparece.
MINOR sobe quando você adiciona algo novo sem tirar nem alterar o que já existia. Uma função nova, um parâmetro opcional adicional com valor padrão que preserva o comportamento anterior, um campo novo numa resposta JSON que ninguém era obrigado a ler — tudo isso é aditivo. Código que funcionava na versão anterior continua funcionando idêntico depois do MINOR; ele só ganha a opção de usar mais coisa, se quiser.
MAJOR sobe quando existe uma mudança que quebra a compatibilidade da API pública. É a única categoria em que código que funcionava antes pode parar de funcionar, ou passar a se comportar de forma diferente, sem que o consumidor tenha feito nada. É também a categoria mais cara de todas para quem consome, porque exige trabalho de adaptação — e por isso é a que os mantenedores mais adiam e mais frequentemente versionam errado por baixo (chamar de MINOR uma mudança que era, na prática, MAJOR).
A garantia do SemVer é assimétrica, e vale internalizar isso. Ela promete que PATCH e MINOR são seguros; não promete nada sobre MAJOR além de avisar que algo quebrou — cabe ao changelog dizer o quê. Isso significa que a maior parte do valor prático de SemVer está em permitir automatizar PATCH e MINOR com confiança razoável, enquanto MAJOR sempre exige atenção humana.
O que é a "API pública" — e por que ela precisa existir antes do número#
SemVer só funciona se você primeiro define o que é a superfície pública do seu projeto. A especificação é explícita sobre isso: você declara sua API pública — pode ser a documentação, pode ser o próprio código, na ausência de outra coisa — e é essa superfície que os números descrevem. Tudo que está fora dela pode mudar livremente, em qualquer versão, sem contar como breaking change.
Sem essa fronteira definida, "breaking change" vira uma discussão sem fim. Uma biblioteca com módulos internos, funções auxiliares não documentadas, ou detalhes de implementação expostos "sem querer" (porque a linguagem não força encapsulamento) acumula usuários que dependem dessas partes internas de qualquer jeito — voltamos a esse ponto adiante, na Lei de Hyrum. Declarar explicitamente "isto é a API pública, o resto pode mudar sem aviso" é o que dá aos mantenedores liberdade para refatorar por dentro sem estar reféns de cada função exportada por acidente.
Na prática, isso significa documentação exportada de forma deliberada. Em Rust, é a diferença entre pub e privado, mais o cuidado de não vazar tipos internos em assinaturas públicas. Em bibliotecas JavaScript, é o que está no exports do package.json, não tudo que tecnicamente dá para importar do arquivo. Em uma API HTTP, é o que está documentado no OpenAPI, não qualquer campo que o servidor happens to retornar.
O que conta como breaking change (e o que não conta)#
Contam como MAJOR: remover uma função, classe, endpoint ou campo público; renomear qualquer um deles sem manter um alias retrocompatível; mudar a assinatura de uma função de forma que chamadas existentes deixem de compilar ou de funcionar (adicionar parâmetro obrigatório, reordenar parâmetros, mudar tipo de retorno); mudar o comportamento observável de algo que já existia — uma função que ordenava resultados de um jeito e passa a ordenar de outro, uma validação que ficou mais estrita e agora rejeita entradas que antes aceitava; e mudar um valor padrão que afeta comportamento, porque quem nunca configurou aquele parâmetro está implicitamente confiando no default antigo.
Não contam como breaking change: corrigir um bug que fazia a API se desviar do que a documentação sempre prometeu (é PATCH, mesmo que alguém dependesse do bug — ver Lei de Hyrum abaixo, sobre por que isso é mais nuançado do que parece); adicionar algo novo e opcional que não interfere no que já existia (é MINOR); melhorar performance sem alterar comportamento observável; mudar detalhes de implementação que estão fora da superfície pública declarada; e atualizar dependências internas de forma transparente para quem consome.
O caso mais discutido é a correção de bug que alguém já depende dela. Formalmente, é PATCH — a documentação nunca prometeu o comportamento com bug, então corrigi-lo não quebra o contrato escrito. Na prática, para quem foi pego de surpresa, a experiência é indistinguível de um breaking change. Esse atrito entre o contrato formal e o comportamento observado real é justamente o que a seção sobre a Lei de Hyrum, mais adiante, discute em profundidade — é a fresta pela qual SemVer, sendo uma convenção humana, escapa de ser uma garantia absoluta.
O caso especial do 0.y.z#
Enquanto a major é 0, o contrato normal de SemVer não vale. A especificação reserva 0.y.z explicitamente para desenvolvimento inicial: a API ainda não é considerada estável, e qualquer coisa pode mudar a qualquer momento, sem aviso maior que o próprio changelog. É o espaço formal para iterar rápido antes de assumir o compromisso de estabilidade que 1.0.0 representa.
Dentro do 0.x, é o MINOR que carrega o peso do MAJOR. Como não existe um MAJOR "de verdade" abaixo de 1.0.0, a convenção que o ecossistema adotou — e que ferramentas como o operador ^ do npm reconhecem explicitamente — é tratar incrementos de MINOR em 0.x como potencialmente incompatíveis: 0.3.0 para 0.4.0 pode quebrar tanto quanto uma major em versão estável. O PATCH (0.3.1, 0.3.2) segue sendo reservado a correções que não mudam a API.
Isso muda a forma como você deveria consumir uma dependência 0.x. Não trate ^0.4.2 como "seguro atualizar automaticamente" do mesmo jeito que trataria ^1.4.2 — o range se comporta de forma mais restrita justamente por isso (mais detalhes na seção de ranges). E, se você é o mantenedor, 1.0.0 não deveria ser lançado cedo demais por ansiedade de parecer "pronto", nem tarde demais por medo de comprometer — é o marco em que você declara, para o mundo, que a API pública parou de ser experimental e passa a valer o contrato cheio.
Pre-release e metadados de build#
A especificação permite qualificar uma versão como pré-lançamento, anexando um sufixo depois de um hífen. 1.0.0-alpha.1, 1.0.0-beta.2, 1.0.0-rc.1 são todas versões que antecedem 1.0.0 — permitem distribuir e testar um candidato sem ainda fazer a promessa de estabilidade da versão final.
A precedência de uma pré-release é sempre menor que a da versão sem sufixo equivalente. Ou seja, 1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0. Entre identificadores de pré-release, a comparação segue regra própria: campos numéricos comparam numericamente, campos alfanuméricos comparam como string em ordem ASCII, e um conjunto com mais campos tem precedência maior que um prefixo idêntico com menos campos (1.0.0-alpha.1 < 1.0.0-alpha.1.1).
Metadados de build vêm depois de um + e não entram na comparação de precedência. 1.0.0+20260812 e 1.0.0+exp.sha.5114f85 são, para efeitos de SemVer, a mesma versão — o metadado é informativo (um identificador de build, um hash de commit), não parte do contrato de compatibilidade. Um gerenciador de pacote correto ignora tudo depois do + ao decidir se duas versões são "iguais" para fins de resolução.
`` 1.0.0-alpha.1+build.7 └──┬──┘ └───┬──┘ └───┬───┘ versão pre-release metadado de build base (afeta (não afeta precedência) precedência) ``
Ranges em gerenciadores de pacote#
SemVer, sozinho, versiona um pacote individual; ranges são o mecanismo que gerenciadores de pacote usam para expressar "qualquer versão compatível" na hora de declarar uma dependência. Os dois operadores mais comuns, popularizados pelo npm e adotados com pequenas variações por outros ecossistemas, são o caret (^) e o til (~).
^1.2.3 significa "compatível com 1.2.3", aceitando qualquer atualização que SemVer promete ser segura — ou seja, aceita até (mas não incluindo) 2.0.0. Na prática, permite MINOR e PATCH livremente, trava MAJOR. É o operador padrão do npm e o mais usado, porque reflete diretamente a promessa central do SemVer: dentro da mesma major, é seguro atualizar.
~1.2.3 é mais conservador: aceita só PATCH, travando também o MINOR — até (mas não incluindo) 1.3.0. Usado quando o consumidor quer só correções de bug, sem risco de ganhar funcionalidade nova que talvez mude sutilmente algum comportamento não coberto pela garantia formal.
O caret se comporta diferente abaixo de 1.0.0, justamente por causa da regra de instabilidade do 0.x. ^0.4.2 aceita só PATCH (equivalente a ~0.4.2 nesse caso), porque, como vimos, o MINOR em 0.x pode quebrar; ^0.0.3 é ainda mais restrito e trava até o PATCH, aceitando só aquela versão exata, porque em 0.0.z nem o PATCH tem garantia forte. Isso costuma surpreender quem não conhece a regra e assume que ^ sempre se comporta igual.
`` ^1.2.3 → aceita [1.2.3, 2.0.0) ~1.2.3 → aceita [1.2.3, 1.3.0) ^0.4.2 → aceita [0.4.2, 0.5.0) (MINOR trava, como MAJOR faria) ^0.0.3 → aceita [0.0.3, 0.0.4) (só aquela versão) ``
O papel do lockfile#
SemVer diz o que é permitido; o lockfile fixa o que foi de fato resolvido. Um range como ^4.17.1 no package.json descreve um conjunto de versões aceitáveis, não uma versão específica — duas instalações em máquinas diferentes, em momentos diferentes, podem resolver esse range para versões diferentes dentro da faixa permitida. O package-lock.json, o Cargo.lock, o go.sum (com o go.mod guardando a versão mínima) existem exatamente para eliminar essa variabilidade: eles registram a versão exata resolvida da vez, incluindo toda a árvore transitiva, e garantem que todo mundo que roda a instalação a partir do lockfile obtém byte-a-byte a mesma árvore de dependências.
Sem lockfile, "funciona na minha máquina" ganha uma causa adicional e sorrateira. Não é só diferença de ambiente — é literalmente uma árvore de dependências diferente, porque o range foi resolvido em momentos diferentes contra o registro de pacotes, que muda todo dia. Um CI que roda npm install sem lockfile versionado pode pegar uma versão MINOR nova publicada entre um build e o próximo, e — mesmo que o mantenedor tenha seguido SemVer corretamente — uma regressão sutil naquela MINOR vira um bug que aparece "do nada" em produção sem nenhuma mudança de código do seu lado.
Lockfile e range não competem; se complementam. O range no manifesto (package.json, Cargo.toml) expressa a intenção — "aceito atualizações de bug e de funcionalidade dentro da major 4" — e é o que orienta uma atualização deliberada (npm update, cargo update). O lockfile expressa o estado resolvido e reprodutível — é o que o build de verdade usa, todo dia, até que alguém decida atualizar de propósito. Comitar o lockfile no controle de versão é o que torna o build reprodutível de fato, não só na teoria do range.
A tensão real: SemVer depende de julgamento humano#
A convenção é seguida por pessoas, e pessoas erram a classificação. Um mantenedor pode honestamente achar que uma mudança é aditiva e publicá-la como MINOR, e só descobrir depois — pelos relatos de quem quebrou — que ela alterava um comportamento do qual gente dependia. Não é malícia nem descuido necessariamente: é a dificuldade genuína de prever todo o espaço de uso de uma API a partir de dentro dela.
A Lei de Hyrum descreve por que isso é estruturalmente inevitável, não um acidente raro. A formulação, associada a Hyrum Wright, diz essencialmente: com um número suficiente de usuários de uma API, não importa o que você prometeu no contrato — todo comportamento observável do sistema será dependência de alguém. Isso inclui bugs, detalhes de timing, ordem de campos num JSON que "não deveria" ser significativa, mensagens de erro exatas que alguém fez parse no texto. Nada disso está na superfície pública declarada, mas tudo isso é, na prática, comportamento observável — e o suficiente de gente vai construir em cima dele.
Isso não invalida SemVer; explica por que ele é uma redução de risco, não uma eliminação dele. Mesmo um PATCH tecnicamente correto — corrigindo exatamente o que a documentação sempre prometeu — pode quebrar alguém que dependia do comportamento incorreto anterior. A resposta prática não é desistir de versionar corretamente, é aceitar que testar antes de atualizar em produção continua sendo necessário mesmo dentro do que SemVer classifica como seguro, e que mudanças amplamente usadas merecem um período de aviso mais generoso do que o mínimo exigido pela especificação.
Boas práticas para quem publica#
Changelog honesto e por versão, não um resumo vago no README. Cada entrada de release deveria dizer, de forma verificável, o que mudou e em qual categoria — Adicionado, Corrigido, Alterado, Removido — para que quem lê saiba, sem adivinhar, se precisa agir.
Deprecar antes de remover, nunca no mesmo passo. O padrão saudável é marcar algo como deprecated numa MINOR — mantendo-o funcional, mas sinalizando (na documentação, em warning de build ou de runtime) que vai sumir — e só de fato removê-lo na próxima MAJOR. Isso dá a quem consome uma janela real para migrar, em vez de acordar com o build quebrado.
Automatizar a classificação com conventional commits reduz o erro humano na hora de decidir o número. Convenções como fix:, feat:, feat!:/BREAKING CHANGE: no corpo do commit permitem que uma ferramenta derive a próxima versão automaticamente a partir do histórico, em vez de depender de alguém lembrar, na hora de publicar, se aquela mudança era PATCH, MINOR ou MAJOR. Não elimina o julgamento — alguém ainda decide como marcar cada commit —, mas centraliza a decisão no momento em que o contexto da mudança está mais fresco, em vez de reconstruí-lo semanas depois na hora do release.
Versionando APIs HTTP#
Uma API sobre a rede tem a mesma lógica de compatibilidade, mas o mecanismo de expor a versão costuma ser mais grosso. É comum ver só a MAJOR na URL (/v1/pedidos, /v2/pedidos), sem MINOR nem PATCH visíveis ao consumidor. A razão é prática: PATCH e MINOR, por definição, não quebram nada — não há motivo para o cliente precisar apontar para uma URL diferente a cada correção de bug ou campo novo aditivo, e forçar isso só multiplicaria a manutenção sem ganho. A MAJOR, por outro lado, é exatamente o ponto em que uma migração deliberada é esperada, então merece um caminho de URL — ou um header de versão — separado, permitindo que /v1 e /v2 coexistam enquanto os consumidores migram no próprio ritmo.
A mesma disciplina de "definir a API pública" vale em dobro aqui. Campos de resposta não documentados no contrato (OpenAPI, por exemplo) tecnicamente podem mudar em qualquer versão — mas, pela Lei de Hyrum, algum consumidor vai depender deles de qualquer jeito. Documentar explicitamente o contrato, e resistir à tentação de expor "só mais um campo interno" sem documentá-lo, é o que mantém a fronteira entre o que pode mudar livremente e o que não pode.
Como consumir dependências com sensatez#
Ler o changelog antes de atualizar continua sendo o passo que a automação não substitui, principalmente em atualizações de MAJOR — é ali que está descrito exatamente o que quebrou e como migrar, informação que nenhum número sozinho carrega.
Testar depois de atualizar, mesmo dentro de um range "seguro". A suíte de testes existe, entre outras coisas, para pegar o caso em que SemVer foi seguido à risca e mesmo assim algo dependia de um comportamento não documentado — o cenário da Lei de Hyrum. Um PATCH ou MINOR que passa verde no CI antes de ir para produção é a rede de segurança contra classificação errada, seja ela do mantenedor ou sua.
Comitar o lockfile e tratar sua atualização como um evento deliberado, não como efeito colateral de qualquer instalação — atualizar a árvore de dependências deveria ser um commit isolado, revisável, que alguém decidiu fazer, não uma mudança acidental que ninguém notou.
Atualizar em passos, não acumulando majors. Pular de v2 direto para v6 de uma dependência que teve quatro majors no meio costuma exigir ler e aplicar quatro guias de migração de uma vez, com o risco composto de todos eles simultaneamente. Atualizar major por major, testando e comitando entre cada uma, isola o risco e torna reversível qualquer passo que dê errado — a mesma lógica que vale para qualquer mudança de estado num sistema em produção.
Fechando#
SemVer não é uma lei da natureza — é um contrato social que o ecossistema de software adotou para tornar previsível algo que, sem convenção nenhuma, seria uma aposta a cada atualização. PATCH corrige sem mudar comportamento, MINOR adiciona sem quebrar, MAJOR é onde a quebra é assumida e comunicada; tudo isso só faz sentido em cima de uma superfície pública claramente definida, porque é ela que separa o que o contrato cobre do que pode mudar livremente. O 0.x existe para a fase em que nada disso ainda se aplica; pre-release e metadado de build existem para qualificar uma versão sem comprometer a precedência; ranges como ^ e ~ são a forma como gerenciadores de pacote traduzem a promessa de compatibilidade em automação; e o lockfile é o que transforma essa promessa em um build reprodutível, fixando o que foi de fato resolvido. Nada disso elimina o julgamento humano nem a Lei de Hyrum — alguém ainda vai depender do que você nunca prometeu —, mas reduz o espaço de incerteza de "vai quebrar ou não vai?" para um contrato legível, e é justamente esse contrato que permite atualizar dependências com confiança em vez de com medo.