Ein praktischer Leitfaden zu Semantic Versioning
^2.4.1 in einer package.json sieht präzise aus, aber das Caret ändert seine Bedeutung je nachdem, ob die Maintainerin am anderen Ende die Regeln, auf denen es aufbaut, tatsächlich befolgt hat. Semantic Versioning (Semver) ist ein in drei Zahlen kodiertes Versprechen – und es funktioniert nur als Versprechen, wenn sowohl Herausgeber als auch Nutzer genau verstehen, wozu es verpflichtet.
Die drei Zahlen
Eine Semver-Version hat die Form MAJOR.MINOR.PATCH, und jede Position hat eine konkrete Bedeutung:
- MAJOR – wird bei inkompatiblen Änderungen erhöht. Alles, was bestehenden, korrekten Code zum Scheitern bringen könnte: eine entfernte Funktion, ein umbenannter Parameter, ein geänderter Rückgabetyp, ein neues Pflicht-Konfigurationsfeld.
- MINOR – wird für neue, abwärtskompatible Funktionalität erhöht. Neue optionale Parameter, neue exportierte Funktionen, neue Konfigurationsoptionen mit sinnvollen Standardwerten.
- PATCH – wird für abwärtskompatible Fehlerbehebungen erhöht. Keine neue Funktionalität, keine Verhaltensänderung außer „das, was kaputt war, funktioniert jetzt wie dokumentiert."
1.4.2 → 1.5.0 heißt „neue Sachen, nichts sollte kaputtgehen." 1.4.2 → 2.0.0 heißt „lies das Changelog, bevor du upgradest." Diese Unterscheidung ist der gesamte Wert von Semver – sie lässt Tools und Menschen Upgrade-Entscheidungen treffen, ohne jeden Commit zu lesen.
Was als „inkompatibel" gilt, ist strenger, als es sich anfühlt
Der Instinkt ist, Major-Erhöhungen für dramatische Rewrites aufzuheben, aber Semvers Definition bezieht sich auf jede inkompatible API-Änderung, egal wie klein:
// v1.2.0
function formatPrice(amountCents) {
return `$${(amountCents / 100).toFixed(2)}`;
}
// v1.3.0 — adding an optional param is fine, existing callers are unaffected
function formatPrice(amountCents, currency = 'USD') {
return `${currency} ${(amountCents / 100).toFixed(2)}`;
}
// v2.0.0 — changing the parameter's meaning breaks every existing caller
function formatPrice(amountDollars, currency = 'USD') {
return `${currency} ${amountDollars.toFixed(2)}`;
}
Die letzte Änderung ist ein einzeiliger Diff und wirkt unbedeutend, aber jeder Aufrufer, der Cent übergibt, bekommt jetzt stillschweigend falsche Ausgaben statt eines Fehlers – was schlimmer ist als eine laute inkompatible Änderung, nicht besser. Es ist ein Major-Bump, egal wie klein die Codeänderung aussieht.
Eine öffentlich exportierte Funktion zu entfernen, eine Funktion von synchron auf Promise-Rückgabe umzustellen, akzeptierte Eingabetypen einzuschränken, Fehlermeldungen zu ändern, auf die Aufrufer per Pattern-Matching prüfen – all das sind inkompatible Änderungen, obwohl sich keine davon wie „ein großer Rewrite" anfühlt.
Versionsbereiche: Was ^ und ~ tatsächlich versprechen
npm install schreibt Bereichsoperatoren in die package.json, und sie ergeben nur im Licht dessen Sinn, was Semver garantiert:
{
"dependencies": {
"lodash": "^4.17.21",
"express": "~4.18.2",
"left-pad": "1.3.0"
}
}
^4.17.21(Caret) – akzeptiert jede Version, die die am weitesten links stehende Zahl ungleich null nicht ändert. Also4.17.21bis (ausschließlich)5.0.0– jedes Minor- oder Patch-Update, keine Majors. Das ist der npm-Standard und die richtige Wahl für die meisten Abhängigkeiten: Man bekommt Fehlerbehebungen und neue Features automatisch, inkompatible Änderungen erfordern eine explizite Aktion.~4.18.2(Tilde) – akzeptiert nur Updates auf Patch-Ebene:4.18.2bis (ausschließlich)4.19.0. Konservativer – neue Features übernimmt man manuell.1.3.0(exakt) – gar keine Updates, ohne diepackage.jsonzu ändern. Angemessen, wenn eine Abhängigkeit eine Vorgeschichte mit gebrochenem Semver hat oder wenn man vollständig reproduzierbare Builds unabhängig vonpackage-lock.jsonbraucht.
Die „am weitesten links stehende Zahl ungleich null"-Regel des Carets hat bei Paketen vor 1.0 eine scharfe Kante: ^0.4.2 akzeptiert nur Patch-Updates (0.4.x), keine Minor-Updates, weil vor 1.0.0 die zweite Zahl als Position für inkompatible Änderungen behandelt wird. Semver dokumentiert 0.x.y ausdrücklich als „alles kann sich jederzeit ändern" – und genau deshalb ist es wichtig, eine Bibliothek auf ein stabiles 1.0.0 zu bringen.
Ein eigenes Paket veröffentlichen: Das Versprechen richtig geben
Wenn du ein Paket pflegst, liegt die Disziplin bei dir, nicht bei deinen Nutzern:
- Entscheide über den Bump, bevor du über den Release entscheidest. Sieh dir den Diff seit dem letzten Release an und ordne ihn ein: irgendeine inkompatible Änderung irgendwo → Major. Keine inkompatiblen Änderungen, aber neue Features → Minor. Sonst → Patch. Wähle nicht zuerst die Versionsnummer und biege dir danach die Kategorie zurecht.
- Verwende
npm versionstatt von Hand zu editieren. Es aktualisiert diepackage.json, erstellt einen Git-Tag und erzwingt gültige Semver-Strings.
npm version patch # 1.4.2 -> 1.4.3
npm version minor # 1.4.2 -> 1.5.0
npm version major # 1.4.2 -> 2.0.0
- Schreibe pro Release einen Changelog-Eintrag, nicht nur ein Commit-Log. „Was ist kaputtgegangen, was ist neu, was wurde behoben" – drei Sätze reichen, aber es ist der Unterschied zwischen einem Nutzer, der selbstbewusst upgradet, und einem, der dein Paket aus Angst für immer festpinnt.
- Deprecate, bevor du entfernst. Markiere die alte API als veraltet (in der Doku, in JSDoc, mit einer Laufzeitwarnung) mindestens für ein Minor-Release, bevor du sie in einem Major entfernst – so bekommen Nutzer ein Migrationsfenster statt einer Überraschung.
Pre-Release- und Build-Metadaten
Semver definiert außerdem Suffixe für Versionen vor der allgemeinen Verfügbarkeit:
2.0.0-alpha.1
2.0.0-beta.3
2.0.0-rc.1
2.0.0
Diese sortieren vor der schlichten Version, der sie vorausgehen (2.0.0-rc.1 < 2.0.0), und Paketmanager installieren sie über einen normalen Bereich nicht, außer sie werden ausdrücklich angefordert – npm install mypackage@next oder eine exakte Pre-Release-Version. So liefert man eine in Arbeit befindliche Major-Version für frühe Tests aus, ohne dass jeder ^1.x.x-Nutzer versehentlich unfertige, inkompatible Arbeit nachzieht.
Fazit
Semver ist ein Vertrag, keine Formalität: MAJOR verspricht „lies das, bevor du upgradest", MINOR verspricht „sicher automatisch zu übernehmen, hier ist etwas Neues", PATCH verspricht „nichts hat sich geändert außer einem behobenen Bug". Die Versionsbereichsoperatoren in package.json schützen dich nur, wenn die Pakete, von denen du abhängst, diesen Vertrag tatsächlich einhalten – und deine eigenen veröffentlichten Pakete schützen deine Nutzer nur, wenn du dasselbe tust.