
Das Release zweimal in die CI packen
GeekBye v1.8.4 verlagerte die Release-Builds für macOS und Windows in die CI. Was das Changelog nicht sagt: Wir hatten das schon einmal versucht, es einen Monat später wegen der Runner-Kosten wieder gelöscht, und erst beim zweiten Mal blieb es kleben — weil das Release-Skript zu dem Zeitpunkt schon von Hand funktionierte.
Das Changelog der v1.8.4 hat eine Zeile, die nach reiner Aufräumarbeit klingt: „Release-Builds kommen jetzt für macOS und Windows aus der CI." Es liest sich wie die Art Sache, die einmal passiert, sauber, und über die man nie wieder nachdenkt. War es nicht. Das Team hatte Releases schon einmal in die CI gesteckt — und wieder herausgerissen. Dieses Release ist der zweite Versuch, und der Grund, warum er klebte, ist die eigentliche Ingenieurslektion, also erzählen wir sie der Reihe nach.
Der erste Versuch, und warum er starb
Im Oktober 2025 gab es eine release.yml. Sie wurde beim Tag-Push ausgelöst — du drückst einen v*.*.*-Tag rein, und ein macOS-Runner fuhr hoch, baute und veröffentlichte. Sie machte ihr eigenes Signier-Setup von Hand: ein Schritt, der das Signaturzertifikat dekodierte, einen temporären Keychain erstellte und entsperrte, das Zertifikat importierte, damit codesign es finden konnte, und ein passender Aufräum-Schritt am Ende. Es funktionierte. Es war auch nur-macOS — Windows-Releases waren überhaupt nicht in der CI.
Am 4. November wurde sie gelöscht. Die Commit-Nachricht ist ungewöhnlich offen über das Warum: „remove GitHub Actions release workflow to conserve minutes / macOS runners cost 10x multiplier (100 billed minutes per 10-minute release). Releases will be done locally." Das ist die ganze Geschichte des ersten Versuchs in einem Satz. GitHub rechnet macOS-Runner-Zeit zum zehnfachen Linux-Tarif ab, und ein tag-getriggertes Release bedeutet, dass jeder einzelne Tag — auch die Wegwerf-Tags, die Re-Tags, die „ups, Versionsbump vergessen"-Tags — still hundert abgerechnete Minuten ausgibt. Automatisierung, die du nicht kontrollierst, ist Automatisierung, die Geld ausgibt, während du schläfst. Also gingen Releases für die nächsten dreieinhalb Monate zurück auf den Mac eines Entwicklers.
Wo die schweren Probleme tatsächlich gelöst wurden
Hier ist der Teil, der den zweiten Versuch funktionieren lässt, und er passierte vollständig außerhalb der CI. Während dieser dreieinhalb Monate lokaler Releases nahm scripts/release.js — das schlichte Node-Skript, das ein Entwickler von Hand ausführt — jedes Packaging-Problem der App auf, ein Fix nach dem anderen:
- White-Label-Restore. Dies ist eine White-Label-Codebase, die sowohl GeekBye als auch Pavleur aus einer Quelle baut, was heißt, dass ein Release
package.json-Felder und Icon-Assets gegen die des Zielprodukts austauscht. Zwei Fixes brachten dem Skript bei, danach alles zurückzusetzen, sodass das Bauen von Pavleur deinen git-Baum nicht mit Pavleurs Identität verschmutzt zurückließ. - Dual-Arch-Windows. Ein Fix, um beide Windows-Installer zu bauen, x64 und arm64, statt nur einen.
- Der Workaround für unsigniertes Windows. Neuere electron-builder-Versionen ignorierten das Config-Flag, das die Windows-Signatur abschalten sollte, also lernte das Skript, einen unsignierten Build zu erzwingen, indem es stattdessen
CSC_IDENTITY_AUTO_DISCOVERY=falsein der Umgebung setzt — eine Falte, die man nur findet, indem man dagegenläuft. - Notarisierungs-Autoerkennung. Das Skript lernte, seine Umgebung anzusehen und zu entscheiden: Signier-Credentials vorhanden? Signieren. Auch Apple-Notarisierungs-Credentials vorhanden? Notarisieren. Keins von beidem? Unsigniert bauen. Keine Flags zu merken; das Vorhandensein der Secrets ist die Konfiguration.
Nichts davon ist glamourös. All das ist die Art Sache, die dich, wenn sie dich in einem CI-Runner überrascht, eine Stunde push-warten-scheitern-Logs-lesen pro Versuch kostet — bei 10x-Abrechnung. Auf einem Mac gelöst, vor dem du sitzt, kostet jedes davon eine Minute.
Der zweite Versuch: sechsundsechzig Zeilen
Als die CI in v1.8.4 zurückkam, war der Workflow 66 Zeilen lang, und seine bestimmende Eigenschaft ist, wie wenig er tut. Der Commit beschreibt es schlicht: „Manual workflow_dispatch trigger that builds both platforms in parallel, reusing existing release.js script. macOS builds are signed and notarized, Windows builds are unsigned." Jede Design-Entscheidung darin ist eine Narbe vom ersten Versuch:
workflow_dispatch, nicht tag-getriggert. Du startest ein Release, indem du auf „Run workflow" klickst. Ein Mensch gibt jede bezahlte macOS-Minute frei. Das Kostenproblem, das die erste Version tötete, wird gelöst, indem man den Trigger schlicht nicht automatisiert — die eine Stelle, an der Automatisierung aktiv schädlich war.- Ein
product-Input. Der Dispatch nimmt ein Dropdown —geekbyeoderpavleur— sodass derselbe Workflow beide Marken ausliefert. Die White-Label-Naht reicht bis hinaus zum Release-Button. - Zwei parallele Jobs.
build-macaufmacos-latest,build-winaufwindows-latest, gleichzeitig laufend. Windows in der CI ist hier wirklich neu; der Oktober-Workflow baute es nie. - Keine Signier-Logik im YAML. Das ist der ganze Punkt. Kein Keychain-Jonglieren, kein Zertifikats-Import-Schritt, kein Aufräumen. Der Mac-Job führt
node scripts/release.js <product> --publishaus und der Windows-Job dasselbe mit--no-sign. Alles, was der erste Workflow von Hand im YAML tat, lebt jetzt in dem Skript, das bereits funktioniert. Der Workflow ist ein Orchestrator, keine Implementierung.
Die zwei Plattformen wollen entgegengesetzte Dinge, und die Jobs spiegeln das ehrlich wider. macOS baut unter einer Hardened Runtime, signiert und notarisiert über electron-builders eingebauten Pfad (@electron/notarize), mit den Credentials aus Repository-Secrets, vom Skript autoerkannt. Windows baut unsignierte NSIS-Installer für x64 und arm64. Ein Workflow, zwei Jobs, zwei völlig verschiedene Vorstellungen von „fertig".
Der Beweis liegt in dem, was nicht passierte
So weißt du, dass die Reihenfolge richtig war: Nach den zwei Anpassungen am selben Tag, die die Läufe benannten und den Namen eines Token-Secrets korrigierten, wurde release.yml vier Monate lang nicht mehr angefasst. Kein Signier-Fehlschlag-Hotfix. Kein Notarisierungs-Ablehnungs-Gehetze. Keine „das native Binary fehlt auf dem Runner"-Panik. Für eine Code-Signier-und-Notarisierungs-Pipeline — das CI-Genre, das am berüchtigtsten fürs Herumzappeln ist — sind vier Monate Stille nahezu unerhört.
Es war still, weil der Lärm schon woanders passiert war, wo es billiger ist. Die Kämpfe, die sich normalerweise in einem CI-Logs-Tab abspielen, bei 10x-Abrechnung, einen Force-Push nach dem anderen, hatten sich während der lokalen Ära auf den Macs der Entwickler abgespielt. Die CI musste nicht der Ort sein, an dem das Packaging durchdebuggt wird, weil das Packaging schon durchdebuggt war. Das ist die These in einer Zeile: Verlagere den Build erst dann von deinem Mac weg, wenn dein Mac aufgehört hat, dich zu überraschen.
Die eine Kopplung, die noch zubeißen kann
Ganz ohne wunde Stelle ist es nicht, und die lohnt sich zu benennen, weil sie subtil ist. Die Swift-Binaries, von denen die macOS-App abhängt — OCR, Bildschirmaufnahme, die Transkriber — sind in git eingecheckt. Aber das Release liefert nicht die eingecheckten Kopien aus; der Build kompiliert sie auf dem Runner neu. Und ein check-swift-version.js-Gate lässt das ganze Release hart scheitern, wenn der Compiler nicht die gepinnte Version ist. Bei diesem Release war dieser Pin Swift 6.2.x, und nichts im Workflow installiert ihn — der Job vertraut schlicht darauf, dass das Standard-Swift von macos-latest passt. An dem Tag, an dem GitHub sein Runner-Image über den Pin hinaus anhebt, stoppt das Release, nicht weil mit der App etwas nicht stimmt, sondern weil die Pipeline still an ein Maschinen-Image gekoppelt ist, das sie nicht kontrolliert. Es ist das eine Implementierungsdetail, das dieser schlanke Workflow nicht in etwas hinuntergeschoben hat, das ihm gehört.
Die andere Hälfte des Releases: kleinere Payloads
Das Release trug ein unabhängiges Feature, das eine Erwähnung wert ist, weil es ein sauberes Beispiel für einen Latenz-Fix durch Weglassen ist. Die Assist-Me-Aktion schickte dem Backend bei jedem Request mehr Kontext, als es brauchte. Ein Commit kürzte zwei Dinge: Der mitgeschickte Transkript-Verlauf sank von den letzten 30 Einträgen auf die letzten 15, und die Profil-Kontextdateien des Nutzers — bei jedem Aufruf in den System-Prompt injiziert — wurden hart auf 4,000 Zeichen mit einer Abschneide-Markierung gedeckelt. Weniger zu serialisieren, weniger hochzuladen, weniger für das Modell zu lesen, bevor es zu antworten beginnt, was die Metrik ist, auf die der Commit optimiert: time-to-first-token. Der Ehrlichkeit halber: Der Commit behauptet die Beschleunigung, misst sie aber nicht — es gibt keine Vorher/Nachher-Zahl in der Historie, also behandle es als gut begründete Kürzung statt als gemessenen Gewinn.
Drei Dinge, die uns dieses Release lehrte
- Die CI ist eine dünne Hülle über einem Skript, das schon funktioniert. Der 66-zeilige Workflow hat keine eigene Signier-Logik; er ruft ein Skript auf, das drei Monate lokaler Releases bereits durchdebuggt hatten. Steck den Mechanismus in etwas, das du von Hand ausführen kannst, und lass die CI nur entscheiden, wann sie es ausführt.
- Automatisiere die Arbeit, nicht unbedingt den Trigger. Der erste Versuch starb, weil tag-getriggerte Releases von selbst 10x-abgerechnete Runner-Minuten ausgaben. Ein manuelles
workflow_dispatchbehält die Automatisierung und entfernt den Teil, der Geld kostete — manchmal ist der Mensch in der Schleife das Feature. - Debugge dort, wo Iteration billig ist. Jeder Packaging-Kampf, der auf dem Mac eines Entwicklers gelöst wird, ist ein Kampf, der nie in einem CI-Logs-Tab zum zehnfachen Preis stattfindet. Verlasse den Mac zuletzt, nicht zuerst.
Für das vorige Kapitel der v1-Geschichte, dreißig Sprachen ohne Sicherheitsnetz ausliefern (v1.8.3); und für den ganzen Bogen, die Anatomie, Software bis zur Perfektion auszuliefern.