Mit Jenkins, fastlane und Git zu besserer Software

Continuous Delivery für iOS-Projekte: Jenkins, fastlane, Git, Xcode
Keine Kommentare

Dieser Artikel gibt einen Einblick in eine mögliche Toolchain für Continuous Integration und Continuous Delivery im Kontext von iOS-Projekten. Es wird gezeigt, wie Jenkins, fastlane, Git und Xcode zusammenspielen können, um den Entwicklern möglichst viel Arbeit abzunehmen und gleichzeitig die Codequalität zu verbessern.

Niemand wird bezweifeln, dass CI/CD heutzutage eine wichtige Rolle in der Softwareentwicklung spielt. Die iOS-Entwicklung ist dabei keine Ausnahme. Eine App kann jedes Mal automatisch getestet werden, wenn es einen neuen Commit gibt oder zwei Branches zusammengeführt werden sollen. Es ist auf diesem Weg genauso einfach, eine neue Version für Alpha- und Betatester bereitzustellen oder im App Store zu veröffentlichen.

Es gibt viele Tools und Plattformen, die dabei helfen, eine CI/CD Pipeline aufzubauen. Was hier vorgestellt wird, ist also nur eine mögliche Lösung. Jeder muss selbst entscheiden, ob sie den eigenen Bedürfnissen entspricht oder andere Tools vielleicht besser zum eigenen Anwendungsfall passen.

Das Set-up

Für unsere Toolchain benötigen wir zusätzlich zu Xcode drei weitere Tools (Kasten: „Was sind das für Tools?“):

  1. fastlane (zur Automatisierung verschiedener Aufgaben)

  2. Git (zur Versionsverwaltung und als Trigger für die verschiedenen Aktionen später)

  3. Jenkins (als primäres CI/CD-Tool)

Was sind das für Tools?

fastlane

fastlane ist ein Open-Source-Tool, führt u. a. Unit- und UI-Tests mit unterschiedlichen Konfigurationen durch und kann auch beim Upload zu HockeyApp, TestFlight/App Store Connect oder Chrashlytics behilflich sein. Ein Tool, mit dem sich relativ einfach Zertifikate und Provisioning Profiles erstellen und aktualisieren lassen, ist ebenfalls Teil des Pakets. Mindestens genauso hilfreich, wenn auch an einer anderen Stelle, ist fastlane, wenn es um automatisiertes Testen geht [1].

Git

Git ist wohl das aktuell am weitesten verbreitete Versionsverwaltungssystem im Bereich der Softwareentwicklung. Ob man dabei eine selbst- oder fremdgehostete Lösung verwendet, spielt in der Regel keine Rolle. Die Befehle für Git sind überall gleich, und für Jenkins gibt es entsprechende Plug-ins für die verschiedenen Git-Hosts. Die meisten Entwickler werden vermutlich bereits mit Git arbeiten. Das hat den Vorteil, dass man ein bereits bestehendes System nutzen kann. In unserem Beispiel werden Commits und Pull Requests (auch Merge Requests genannt) verwendet, um automatisiert entsprechende Aktionen auszulösen – vom Testen der App bis hin zum Upload zu TestFlight bzw. App Store Connect [2].

Jenkins

Jenkins ist ein Open-Source-Server für Automatisierungstechnik. Er überprüft regelmäßig die verbundenen Repositories auf neue Commits und Pull Requests. Wenn er einen findet, wird ein automatisierter Durchlauf mit den entsprechenden Aufgaben gestartet, z. B. Unit-Tests oder ein Ad-hoc Build. Diese Läufe werden in der Jenkins-Welt als Job bezeichnet [3].

Automatisieren mit fastlane

Wer als iOS-Entwickler fastlane noch nicht kennt, sollte es sich auf jeden Fall einmal anschauen. Es hilft dabei, immer wiederkehrende Aufgaben zu automatisieren und gibt uns dadurch mehr Zeit (Kasten: „Build- und Testzeiten verringern“), uns auf unsere eigentliche Aufgabe zu konzentrieren – das Schreiben von Code. Mit fastlane kann man mit nur einem Befehl Unit-Tests auf mehreren Geräten und iOS-Versionen testen, Screenshots erstellen, die App bauen, signieren und zu TestFlight hochladen lassen. Und das sind noch nicht alle Funktionen.

Die Power von Git

Werfen wir mal einen genaueren Blick auf Git und wie wir es verwenden können, um damit die richtigen Jobs zur richtigen Zeit auszulösen – nicht mehr oder weniger. Wir haben bereits gehört, dass Jenkins dazu auf Commits und Pull Requests hört. Aber woher weiß er, wann er welche Aktionen ausführen soll?

Dazu benötigt man ein Branching-Modell, in dem definiert ist, welche Arten von Branches es gibt und nach welchen Regeln diese gemergt werden dürfen. Für uns hat sich ein Branching-Modell, dass an Git Flow [4] angelehnt ist, als praktisch erwiesen. Es besteht aus den folgenden Arten von Branches: Master, Develop, Feature, Bugfix und Story. Die ersten vier sind vermutlich jedem bekannt und müssen nicht weiter erläutert werden. Interessant wird es auch erst bei den Story-Branches. Diese verwenden wir für Features, die zu groß sind, um sie in einem Pull Request vernünftig reviewen zu können. Sie haben allerdings trotzdem das Präfix feature/ und unterscheiden sich lediglich durch ihre Feature-Branches von einem normalen Feature-Branch. Letztendlich sind sie ja auch nichts anderes (Listing 1).

feature/<feature-ID>-<feature-title> // Feature-Branch
feature/<story-ID>-<story-title> // Story-Branch
feature/<story-ID>/<feature-ID>-<feature-title> // Story-Feature-Branch

Kommen wir nun zu den Regeln. Jeder Branch wird von seinem nächsthöheren Branch aus erstellt und später auch wieder in diesen zurückgemergt. Also kommt z. B. ein Feature-Branch von develop und geht später wieder zurück nach develop, ein Story-Feature-Branch kommt vom Story-Branch und geht – genau – dorthin auch wieder zurück (nicht in develop oder gar master). Nur vom develop-Branch aus darf nach master gemergt werden.

Je nachdem welcher Branch wohin gemergt wird, wollen wir andere Aufgaben ausführen. So soll jedes Mal, wenn ein Pull Request nach develop abgeschlossen ist, dieser einmal durch Unit-Tests prüfen, dass nichts kaputtgegangen ist, und im Anschluss daran soll eine neue Alphaversion gebaut und zu der entsprechenden Verteilerplattform hochgeladen werden. Für jeden gestellten Pull Request wollen wir jedoch nur die Unit-Tests ausführen.

Jenkins CI

Um den gesamten CI-Prozess zu orchestrieren, verwenden wir Jenkins. Jenkins läuft als Programm auf einem Server. Mit welchem Betriebssystem der Server läuft, ist dabei egal. Für viele ist ein Linux-Server die einfachste Variante. Nun gibt es aber gerade für iOS-Projekte die Einschränkung, dass sie nur auf einem macOS-System gebaut werden können. Jenkins dient daher hauptsächlich zur Steuerung der Jobs, die erledigt werden sollen. Diese teilt er anderen Rechnern zu, die in der Lage sind, die Aufgaben auszuführen. Solche Rechner werden bei Jenkins als Knoten bezeichnet. Eine Jenkins-Instanz kann mehrere solcher Knoten verwalten. Die eigentlichen Aufgaben werden dann auf dem jeweiligen Knoten ausgeführt.

Aber auch wenn Jenkins auf einem macOS-Rechner laufen würde, würde ich nicht empfehlen, Jobs direkt auf der Hauptinstanz von Jenkins auszuführen. Stattdessen sollte es immer mindestens einen Knoten geben, an den Jenkins die Jobs delegieren kann. Zum einen ist die Gefahr geringer, sich die eigene Jenkins-Instanz zu zerschießen, zum anderen hat Jenkins ausreichend Ressourcen frei, um regelmäßig in Git nach Änderungen zu suchen. Die verwendeten Knoten können jedes Betriebssystem haben, das einem hilfreich erscheint. Deshalb ist es womöglich sinnvoll, verschiedene Knoten für verschiedene Arten von Projekten zu haben – iOS, Android, Web, Windows etc.

Je nachdem wie häufig Pull Requests im Projekt gestellt werden und wie lange ein dadurch ausgelöster Job dauert, kann es sinnvoll sein, mehr als einen Knoten zu haben. So können Jobs parallel laufen und müssen nicht immer warten, bis die vorigen Jobs abgeschlossen sind.

Bis vor einigen Jahren war es üblich, per Hand je einen Job für die verschiedenen Arten von App-Konfigurationen und -Situationen, die man testen wollte, anzulegen und zu konfigurieren. So kam es schon mal dazu, dass man mehrere Jobs für ein Projekt hatte, z. B.:

  • Ausführen von Unit-Tests für einen Pull Request

  • Bauen und hochladen zu Crashlytics

  • Bauen, signieren und hochladen zu TestFlight

Diese mussten zwar in der Regel nur einmal zu Beginn eines neuen Projekts erstellt werden, aber das kostete jedes Mal einiges an Zeit und Aufwand, bis es endlich so lief wie gewollt.

Vor einiger Zeit haben sich die Macher von Jenkins etwas Neues einfallen lassen: Pipelines.

Pipelines sind Abfolgen von einzelnen Aufgaben, die über ein Skript konfiguriert sind. Alles, was man früher bei einem Job händisch konfigurieren musste, lässt sich nun über ein Skript beschreiben. Das bedeutet, dass man diese Jobs nicht mehr manuell konfigurieren muss. Man kann das Skript oft mit nur wenigen Änderungen von einem alten Projekt in ein neues kopieren. Die zu erledigenden Aufgaben sind ja meist identisch: Unit-Tests ausführen, App bauen und hochladen zu verschiedenen Plattformen. Noch einfacher wird es dadurch, dass Jenkins alle unsere Repositories einschließlich der darin enthaltenen Branches regelmäßig scannen kann und darin nach diesen Skripten sucht. Wenn er eines dieser Skripte findet – sie heißen Jenkinsfile – erstellt er automatisch einen neuen Job, der die entsprechenden Aufgaben im richtigen Moment ausführt.

Im Jenkinsfile kann man auch bestimmen, welche Aufgaben für einen bestimmten Branch abgearbeitet werden sollen (Listing 2). Es wird zudem ins Repository gelegt, so kann Jenkins es finden und hat direkt eine Anleitung, was mit unserem Projekt zu tun ist.

if(env.BRANCH_NAME == 'develop') {
 # run unit tests for one iOS version
  # build alpha version</em>
  # upload alpha version to TestFlight
}

Ablauf

Da wir nun die Einzelteile kennen, stellt sich die Frage, wie das Ganze abläuft. Schauen wir uns den Fall an, dass wir ein neues Feature von einem Feature-Branch nach develop mergen wollen.

Sobald der Pull Request gestellt ist, stellt Jenkins das fest und schaut sich den Branch genauer an. Dort findet er das Jenkinsfile. Darum sucht er jetzt den nächsten freien Knoten, der ein iOS-Projekt baut, und führt die entsprechenden Anweisungen aus dem Jenkinsfile aus. In unserem Beispiel würde der Knoten jetzt die Unit-Tests für alle unterstützten Plattformen durchführen. Das Ergebnis wird im Anschluss an den Pull Request zurückgespielt und dort angezeigt. Somit weiß ein Reviewer, ob der Pull Request gemergt werden kann oder nicht. Angenommen, die Tests waren erfolgreich, kann der Reviewer schließlich den Branch mergen. Die Änderung am Develop Branch bekommt Jenkins natürlich auch mit und bemüht wieder das Jenkinsfile, diesmal das auf develop. Wieder sucht er den nächsten freien, passenden Knoten. Auf dem Knoten werden jetzt aber nur noch die Unit-Tests für eine iOS-Version durchgeführt. Dafür wird eine neue Alphaversion gebaut und direkt zu TestFlight hochgeladen.

Build- und Testzeiten verringern

Wenn eine App größer wird, steigt auch unweigerlich die Zeit, die benötigt wird, um sie zu bauen. Im Idealfall steigt auch die Anzahl der Unit-Tests, was wiederum zu längeren Testzeiten führt. Da nicht jeder die Ressourcen hat, um eine beliebe Anzahl von Jobs parallel laufen zu lassen, kann es bei größeren Teams zu einem Stau der Jobs kommen. Um dem entgegenzuwirken, gibt es mehrere Möglichkeiten, die Build- und Testzeiten zu verkürzen:

Neues Build-System in Xcode

Bei lokalen Builds während der Entwicklung kann die Verwendung des neuen Build-Systems die Build-Zeiten von Swift merklich verkürzen. Dabei werden nur die Teile der App neu gebaut, die seit dem letzten Build auch wirklich verändert wurden oder aufgrund dieser Veränderungen neu gebaut werden müssen. Eingeführt mit Version 9, ist es seit Xcode 10 das Standard-Build-System, wenn man ein neues Projekt anlegt. Ältere Projekte kann man ganz einfach auf das neue System umstellen: über File | Workspace Settings | Shared Workspace Settings | New Build System. Genauso kann man natürlich wieder auf das alte System zurückwechseln, sollte es mal nötig sein.
Auf einem CI-Server macht sich das New Build System allerdings kaum bemerkbar. Dort wollen wir in der Regel einen Clean Build machen, also alles neu bauen, um mögliche Konflikte zwischen verschiedenen Builds zu vermeiden.

Testen ohne Bauen

Ein Clean Build einer größeren App kann schon mal einige Zeit dauern. Die Tests hingegen laufen meistens in einem Bruchteil dieser Zeit durch. Wir möchten diese App allerdings mit mehreren unterschiedlichen iOS-Versionen testen. Würden wir jedes Mal die App neu bauen, wäre der Knoten einige Zeit beschäftigt. Zum Glück gibt es da einen Weg, mit dem man die App nur einmal bauen muss, dann aber auf unterschiedlichen Geräten und iOS-Versionen testen kann.
Für xcodebuild und auch für fastlane scan gibt es das Flag test_without_building. Damit werden nur die Tests ausgeführt, ohne dass die App nochmal gebaut wird (Listing 3).

scan(
  scheme: YOUR_SCHEME,
  clean: false,
  test_without_building: true
)
// Voraussetzung ist natürlich, dass die App bereits einmal mit den 
// entsprechenden Testinformationen gebaut wurde. Das kann man mit 
// dem Flag build_for_testing: true in fastlane machen oder indem man 
// einmal scan normal durchlaufen lässt, dann wird auch zunächst die 
// App mit den nötigen Testinformationen gebaut und gleich getestet.

Ausblick

Wie wir gesehen haben, gibt es einige Möglichkeiten, wie wir als Entwickler weniger Zeit für repetitive Aufgaben verschwenden müssen und die dadurch auch noch helfen, eine gute Codequalität zu gewährleisten.

Für alle, die sich keinen eigenen Server für solche Aufgaben hinstellen können oder mögen, gibt es auch gehostete Plattformen, die sich auf Continuous Integration und Delivery spezialisiert haben. Fast alle unterstützen auch fastlane, sodass man ein einmal vorgenommenes Set-up für alle wiederverwenden kann.

 

 

 

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -