Engineering-Artikel

Drift bei der Swift-Codegenerierung im Cloud-Mac-CI verhindern

Drift bei der Swift-Codegenerierung im Cloud-Mac-CI verhindern

Derselbe Commit kann auf dem Rechner eines Entwicklers unverändert bleiben, im Cloud-Mac-CI unter Generated/ jedoch Dutzende abweichende Zeilen erzeugen. Noch problematischer ist, dass sich das Projekt unter Umständen weiterhin kompilieren lässt. Dadurch gelangen veraltete Konstanten, fehlende Ressourcenschlüssel oder überholte Mocks in die Build-Artefakte. Solche Probleme lassen sich nicht beheben, indem lediglich ein weiterer Generierungsbefehl ergänzt wird. Generatorversion, Eingabemenge, Ausgabeverzeichnis und Abnahmeregeln müssen gemeinsam Teil des Build-Vertrags werden.

Zuerst die Ursache der Drift bestimmen

Die Ausgaben von Werkzeugen wie SwiftGen und Sourcery hängen von vier Faktoren ab: der Version der ausführbaren Datei, der Konfiguration und den Templates, den Eingabedateien sowie der Laufzeitumgebung. Nur die Konfigurationsdatei zu fixieren, reicht nicht aus. Das über den PATH gefundene Werkzeug kann auf einem Entwicklerrechner neuer sein als im CI. Auch die Reihenfolge beim Durchlaufen von Dateien kann sich je nach Verzeichniszustand ändern, während Locale-Einstellungen die Sortierung oder Datumsformate beeinflussen können.

Zunächst sollten lokal und im CI dieselben minimalen Diagnosedaten erfasst werden:

set -euo pipefail

sw_vers
xcodebuild -version
swiftgen --version
sourcery --version
printf 'LANG=%s
' "${LANG:-unset}"
printf 'PWD=%s
' "$PWD"
git status --short

Dabei geht es nicht darum, möglichst viele Informationen zu sammeln, sondern festzustellen, welche Daten der Generierungsprozess tatsächlich eingelesen hat. Schreibt ein Template die aktuelle Uhrzeit, einen absoluten Pfad oder eine zufällige Kennung in die Ausgabe, müssen diese nicht deterministischen Eingaben zuerst entfernt werden. Andernfalls entstehen bei jedem Lauf zwar nachvollziehbare, aber bedeutungslose Unterschiede.

„Erneut generierbar“ bedeutet nicht „mit demselben Ergebnis generierbar“. Das Gate prüft, ob identische Eingaben bytegenau identische Artefakte erzeugen.

Eine reproduzierbare Generierungsbasis schaffen

Das Generierungsverzeichnis sollte vollständig den Werkzeugen gehören und keine manuell gepflegten Swift-Dateien enthalten. Das alte Verzeichnis muss vor jedem Lauf gelöscht werden. So werden Typen sichtbar, die zwar aus den Eingaben entfernt wurden, aufgrund inkrementeller Generierung aber zurückgeblieben sind. Für Konfigurationen, Templates und Eingabeverzeichnisse sollten deterministische Pfade relativ zum Repository-Stamm verwendet werden, statt vom aktuellen Arbeitsverzeichnis des Aufrufers abzuhängen.

Die Zuständigkeiten lassen sich wie folgt strukturieren:

Element Fixierung CI-Abnahme
Generator Version fixieren und ausgeben Entspricht der Deklaration im Repository
Konfiguration und Templates Unter Versionskontrolle stellen Keine temporären Änderungen im Arbeitsbereich
Eingaben Verzeichnisse und Erweiterungen festlegen Sortierte Eingabemenge bleibt stabil
Ausgaben Eigenes Verzeichnis verwenden Vor der Generierung leeren
Umgebung Locale-Einstellungen fixieren Keine Zeitangaben oder absoluten Pfade schreiben

Wie ein Team die Werkzeuge installiert, kann unterschiedlich geregelt sein. Für die Versionsdeklaration darf es jedoch nur eine einzige maßgebliche Quelle geben. Es sollte nicht eine Version im Skript, eine andere in der Entwicklerdokumentation und eine dritte über den PATH des Cloud-Mac verwendet werden.

Die Differenzprüfung vor der Kompilierung ausführen

Wird die Generierung vor der Kompilierung ausgeführt, erfolgt die Fehlerrückmeldung schneller und Compilerfehler können die eigentliche Ursache nicht verdecken. Das folgende Skript setzt voraus, dass sich alle generierten Dateien in Generated/ befinden und im Repository eingecheckt sind:

set -euo pipefail

repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"

export LANG=C
export LC_ALL=C

rm -rf Generated
mkdir -p Generated build

swiftgen config run --config swiftgen.yml
sourcery --config sourcery.yml

find Generated -type f -print |
  LC_ALL=C sort |
  while IFS= read -r file; do
    shasum -a 256 "$file"
  done > build/generated.sha256

git diff --exit-code -- Generated
git status --short --untracked-files=all Generated

git diff zeigt nur Änderungen an bereits verfolgten Dateien. Deshalb müssen auch nicht verfolgte Dateien geprüft werden. Das eigentliche Gate sollte ausdrücklich fehlschlagen, sobald git status eine Ausgabe erzeugt. Außerdem sollten der Differenz-Patch und generated.sha256 als Build-Artefakte gespeichert werden. Das Hash-Manifest ersetzt die Differenzprüfung nicht, beantwortet aber die Frage: „Welche konkrete Dateimenge hat dieser CI-Lauf erzeugt?“

Drei Fehlertypen unterscheiden

Ändert sich nur die Formatierung, sind Zeilenumbrüche in den Templates, Werkzeugversionen und die Ausführungsreihenfolge der Formatierer zu prüfen. Werden Dateien hinzugefügt oder entfernt, müssen Eingabemenge und Bereinigungslogik untersucht werden. Ändern sich Inhaltswerte, sind Ressourcen, API-Beschreibungen oder Template-Parameter zu kontrollieren. Erst nach dieser Klassifizierung sollte die Ursache behoben werden. Die Artefakte im CI einfach zu überschreiben und danach weiterzukompilieren, ist keine Lösung.

Nebenläufigkeitsfallen in Xcode-Build-Phasen vermeiden

Wenn mehrere Targets dasselbe Generierungsverzeichnis verwenden, kann das Einfügen des Generierungsbefehls in die Run Script Phase jedes Targets zu gleichzeitigen Schreibzugriffen auf dieselben Dateien führen. Manchmal wird der Befehl dadurch nur mehrfach ausgeführt, in anderen Fällen entstehen abgeschnittene oder vorübergehend fehlende Dateien. Robuster ist es, die Generierung als eigenständigen vorgelagerten CI-Schritt auszuführen und sämtliche xcodebuild-Aufgaben erst nach dessen Abschluss zu starten.

Muss die Generierung in einer Xcode-Build-Phase stattfinden, darf es nur einen Verantwortlichen geben. Zudem müssen vollständige Listen der Ein- und Ausgabedateien deklariert werden. Das Generierungsskript selbst darf keine Projektdateien ändern und das Generierungsverzeichnis nicht leeren, während es von einem anderen Prozess gelesen wird. Benötigen parallele Builds getrennte Ausgaben, können anhand der Aufgabenkennungen temporäre Verzeichnisse angelegt werden. Nach erfolgreicher Prüfung wird das Ergebnis dann durch eine einzige atomare Ersetzung am festen Ziel veröffentlicht.

Ein weiterer häufiger Fehler besteht darin, direkt nach der Generierung einen Formatierer auszuführen, während der lokale Pre-Commit-Hook die umgekehrte Reihenfolge verwendet. Generierung, Formatierung und Differenzprüfung müssen stets in derselben Reihenfolge und über dasselbe Einstiegsskript ausgeführt werden.

Checkliste vor dem Merge und bei Störungen

Bei Commits mit Änderungen am Generator oder an dessen Eingaben sollte das Code-Review mindestens folgende Punkte prüfen:

  1. Die Deklaration der Generatorversion wurde konsistent aktualisiert.
  2. Änderungen an Konfiguration, Templates und Eingaben befinden sich im selben Commit.
  3. Nach dem Entfernen einer Eingabe verschwindet auch die zugehörige generierte Datei.
  4. Die Artefakte enthalten weder Zeitangaben noch Benutzernamen oder absolute Pfade.
  5. Nach zwei aufeinanderfolgenden lokalen Ausführungen erzeugt der zweite Lauf keine Unterschiede.
  6. Das CI prüft vor der Kompilierung sowohl verfolgte als auch nicht verfolgte Dateien.
  7. Parallele Aufgaben schreiben in voneinander isolierte Verzeichnisse.
  8. Bei einem Fehler bleiben Werkzeugversionen, Differenz-Patch und Hash-Manifest erhalten.

Bei der Fehlersuche sollte nicht zuerst eine automatische Korrektur ausgeführt werden, die den Ausgangszustand überschreibt. Zunächst sind git status, Werkzeugversionen und die Differenz zu sichern. Anschließend wird der Generierungseinstieg aus einem sauberen Checkout erneut ausgeführt. Bleiben die Ergebnisse auch bei einem sauberen Checkout inkonsistent, liegt die Ursache in der Umgebung oder in nicht fixierten Eingaben. Tritt der Fehler nur bei wiederverwendeten Arbeitsbereichen auf, sollten insbesondere alte Dateien, Caches und gleichzeitige Schreibzugriffe geprüft werden. Der abschließende Maßstab ist einfach: Für denselben Commit muss jeder Cloud-Mac-Job die Generierung zweimal hintereinander ausführen können, ohne dass beim zweiten Lauf ein beobachtbarer Unterschied entsteht.

Häufig gestellte Fragen

Sollten generierte Swift-Dateien eingecheckt werden?

Ja, wenn Entwicklung oder Review ohne lokal installierten Generator funktionieren sollen. Das CI muss sie trotzdem neu erzeugen und bei nicht eingecheckten Änderungen fehlschlagen.

Warum unterscheiden sich lokale und im CI erzeugte Dateien?

Häufige Ursachen sind Tool-Versionen, Eingabereihenfolge, Gebietsschema, Zeilenenden und Arbeitsverzeichnis. Diese Bedingungen müssen vor dem Kompilieren festgelegt werden.

VMMini M4

Die nächste Aufgabe auf einem exklusiven physischen Knoten ausführen

Wählen Sie Knoten und Abrechnungszeitraum und starten Sie die Bereitstellung mit der festen Konfiguration aus M4, 16 GB RAM und 256 GB SSD. Die tatsächliche Verfügbarkeit richtet sich nach der Echtzeitanzeige in der Konsole.

Jetzt einen Cloud-Mac mieten