Continuous Integration mit Java und JavaScript, Teil 1: Die Kunst der Versionierung

Projektstruktur und Zielsystem

Grundsätzlich lassen sich Build-Projekte in zwei Strukturen unterscheiden: der integrierte Build und die Build-Kette. Letztere bedeutet die Organisation der Module in kleineren Einheiten und den Build-Prozess in der Reihenfolge nach Abhängigkeiten. Das hat den Vorteil, dass die Teams unabhängig voneinander arbeiten. Sie müssen lediglich die Übergabe der Artefakte und die Verwaltung der Versionssnummern definieren. Letzteres ist der große Schwachpunkt dieses Ansatzes, da ein langwieriger Prozess zur Bestimmung nötig ist oder das Unternehmen die Propagierung der Versionsnummer für die Build-Kette implementieren muss. Zudem sind die integrierte Entwicklung und das gleichzeitige Arbeiten an zwei Teilprojekten nicht möglich.

Bei einer integrierten Projektstruktur werden alle notwendigen Teilprojekte als Ganzes gebaut. Diese Vorgehensweise hat allerdings den wesentlichen Nachteil, dass die Länge der Bauzeit enorm ansteigen kann. Der Autor kann von Bauzeiten in der Größenordnung von mehrerer Stunden berichten, die durch umfangreiche Integrationstests hervorgerufen wurden. Zwar ist es möglich, die Integrationstests abzutrennen, aber das Ergebnis wäre wieder eine Build-Kette. Eine Mischform wäre denkbar, sofern das Team plant, an welcher Stelle die langwierigen Integrationstests stattfinden – vorzugsweise am Ende der Kette.

Das Zielsystem ist ein weiteres Problemfeld, das hier nur am Rande angeschnitten wird. Da im Beispiel das Zielsystem ein WAR-Archiv ist, ist die Bedingung der Unabhängigkeit vom Betriebssystem erfüllt. Beim Design eines CI-Workflows mit mehreren Zielsystemen – etwa CPU-Architekturen – spielt eben diese Unabhängigkeit eine wichtige Rolle. Ist sie nicht gegeben, könnte man mit Virtualisierungs-Tools wie Docker, Vagrant oder Uni-Kernels den Build-Prozess unabhängig vom Betriebssystem gestalten. Diese Tools sind wiederum eher in der Peripherie von Continuous Delivery verortet, die aber nicht Thema dieses Artikels sind.

Die Bedeutung der Versionsnummern

In den beschriebenen Problemzonen schwingt immer ein gemeinsamer Begriff mit: die Versionsnummer. Sie ist in erster Linie zur Unterscheidung von Modulen oder Varianten notwendig. Die einzige Bedingung für die Reproduzierbarkeit von Builds ist die Eindeutigkeit dieser Nummern. Üblicherweise ist dafür der Build-Server verantwortlich, beispielsweise indem der Build-Prozess ein Versions-Tag nach dem Bauen und vor der Veröffentlichung in das Versions-Kontrollsystem schreibt oder abbricht, wenn es bereits vergeben wurde.

Bei Build-Ketten ergeben sich jedoch weitere Schwierigkeiten: Um einen reproduzierbaren Build zu gewährleisten, muss sichergestellt werden, dass die konkrete Auswahl der Abhängigkeiten deterministisch ist. Das ist nur möglich, wenn eine Versionsordnung definiert ist. Zeitstempel als Version zu nutzen, etwa mit Mavens SNAPSHOT-Notation, ist keine praxistaugliche Ordnung, da sie im Build und in den erzeugten Artefakten nicht unterscheidbar sind. Dadurch kommt es in langen Build-Ketten zu merkwürdigen Phänomenen: Das erste Projekt wird erfolgreich gebaut, aber ein paar Schritte später bricht die Kette ab, weil eine neuere, jedoch inkompatible SNAPSHOT-Version verfügbar ist. Das ist ärgerlich, aber im Sinne von Continuous Integration so gewollt.

Im Gegensatz dazu kann es vorkommen, dass eine fehlerhafte Änderung nicht erkannt wird. Ein Beispiel mit einer Build-Kette bestehend aus den drei Modulen A, B und C soll das Problem verdeutlichen: Modul C benötigt Modul B, das wiederum Modul A integriert. Modul C ist das Ende der Build-Kette. Beim Bauen von Modul C sind die Änderungen des Moduls A im Modul B nicht verfügbar, wenn der Bau einer neuen SNAPSHOT-Version von Modul A nach der Ablage von Modul B erfolgt. Die Änderungen von Modul A zeigen sich in Modul C erst nach dem erneuten Build der Module B und C, obwohl sie selbst unverändert sind. Baut das System nur die Projekte mit einer erkennbaren Änderung, so sind diese Integrationsprobleme erst bei der Vergabe konkreter Versionen sichtbar, also erst zum Release-Zeitpunkt.

Wie erwähnt unterstützen manche Build-Systeme und Repositories Wertebereiche von Versionsnummern. In dem Fall können Abhängigkeiten in Form von Bedingungen formuliert werden, etwa 'größer als Version "1.0", aber kleiner als Version "2.1.1-rc5"'. Ist die Version "2.1.1-rc5" größer oder kleiner als "2.1.1-hotfix1"? Offensichtlich muss hierfür eine Versionsordnung bei allen Beteiligten klar definiert sein. Je nach System bieten sich hierfür verschiedene Modelle an, etwa Semantic Versioning oder die .NET-Versionskonvention. Meistens ist in den Versionsschemata ein einfacher Zähler vorhanden, dessen Änderung lediglich eine "kompatible" Änderung andeutet. Es liegt auf der Hand, den Counter beim Bauen automatisch zu erhöhen. So wäre es ein Leichtes, die Abhängigkeit so zu formulieren, dass beispielsweise immer der höchste Versionszähler verwendet wird – in Bezug zu einem abgestimmten fixen Versionsteil. Beispiel: In diesem Quartal hat Modul A die Version 5.1 und Modul B die Version 3.3. Demnach könnte Modul A die Abhängigkeit wie folgt definieren:

Modul A abhängig von B: >=3.3.0 & <3.4.0

Der Build ist reproduzierbar, solange im Integrationsmodul die Versionsnummern immer eindeutig selektiert werden können. Oft ist das nicht möglich – etwa bei einem Versionskonflikt oder wenn der Build des Integrations-Moduls immer zum gleichen Ergebnis kommen soll. Daher kann es ratsam sein, die vom Dependency-System errechneten Versionsnummern, also die effektiv genutzten, im Integrationsmodul zu fixieren, und das möglichst automatisch.

Dabei können Versionskonflikte auftreten, wenn eine Abhängigkeit zu einer Komponente in Konflikt zu einer gemeinsamen Abhängigkeit steht. Manche Dependency-Systeme versuchen, diese Versionskonflikte zu lösen. Vermutlich ist das Ermitteln der korrekten Versionsnummern nicht effizient möglich. Zudem kommt es vor, dass das System immer eine bestimmte Version eines Moduls selektiert, obwohl neuere verfügbar wären. Folgendes Beispiel soll das Problem anhand der Module A, B und C verdeutlichen:

Modul A abhängig von:

B: >=3.3.0 & <3.4.0

C: >=1.1.0 & <2.0.0

Es kommt zu einem Versionskonflikt, wenn Modul B nun ebenfalls von Modul C abhängig ist, beispielsweise mit:

Modul B abhängig von:

C:>= 0.0.0 <1.0.0

In diesem Fall erkennt das Dependency-Management des Build-Systems einen Versionskonflikt. Eine Versionsnummer kann durch eine solche Einschränkung einer anderen Abhängigkeit ungewollt fixiert werden. Im obigen Beispiel wäre die effektiv selektierte Version von Modul C immer kleiner als "1.5", auch für den Fall, dass eine neuere Version von Modul C verfügbar wäre, wenn im Modul B die Abhängigkeit zu C wie folgt eingeschränkt ist:

Modul B abhängig von:

C:>=0.0.0 & < 1.5.0

Für Build-Ketten bedeutet das sprichwörtlich die Wahl zwischen Pest und Cholera: Fixierte Versionsnummern erfordern das Verteilen gepflegter Listen. Ansonsten führen potenzielle Versionskonflikte zum Abbruch von Build-Ketten beim Release-Zeitpunkt (SNAPSHOT). Die Alternative einer programmatischen Propagierung der Versionsnummer ist aufwendig zu implementieren, da sie eine automatische Änderung im Versionskontrollsystem durch die Fixierung der Version zur Folge haben kann. Hier sind Build-Systeme, die den manuellen Eingriff ermöglichen, klar im Vorteil.

Die Entscheidung über das Propagieren von Versionsnummern in einer Build-Kette hängt in erster Linie davon ab, ob die Teams sich auf ein gemeinsames Versionsschema einigen können (oder ihnen eine Einigung aufgezwungen wird). Erst bei der Existenz eines gemeinsamen Schemas lohnt sich die Überlegung, ob ein Automatismus hinsichtlich der Version einen Vorteil hat.