Kurzfassung Wir verwenden eine Reihe von CLI-Tools in TwinCAT-CI/CD-Pipelines. Diese Tools zuverlässig und versioniert auf unsere Build Nodes zu bringen war ein Problem, das wir lange mit Skripten mehr schlecht als recht gelöst haben. Wir haben Chocolatey, NuGet und einige weitere Optionen geprüft und sind schließlich bei Scoop und einer token-authentifizierten Download-API gelandet. Das Ergebnis: Tools aktualisieren sich selbst, jeder Download wird protokolliert, und das Einrichten eines neuen Nodes dauert Minuten statt eines Nachmittags.


Teams benötigen DevTools (CLIs für Builds, Dokumentationsgenerierung und Paketverwaltung) installiert und aktuell auf jedem Build-Node. Wir selbst nutzen Jenkins, aber das Problem und die Lösung sind unabhängig davon, welches CI/CD-System zum Einsatz kommt. Das klingt nach einer Routineaufgabe, in der Praxis steckt allerdings mehr dahinter.


Das Problem: CI/CD-Abhängigkeiten auf Windows-Nodes verwalten

Jedes Team mit selbst gehostetem CI/CD auf Windows stößt früher oder später auf dieselbe Frage: Wie bekommt man die Tools, die die Pipelines brauchen, auf jeden Build-Node, hält sie auf dem richtigen Versionsstand, und muss danach nicht mehr daran denken?

Für die meisten Software-Stacks ist das ein gelöstes Problem. Linux-Agents haben apt oder yum. Container-basiertes CI umgeht es ganz. Auf Windows-Build-Nodes ist die Antwort weniger offensichtlich, und sie wird komplizierter, wenn die Tools proprietär und zugangsbeschränkt sind.

Bei uns sind es kommerzielle CLI-Binaries, die an Kunden mit aktivem Abonnement ausgeliefert werden. Das fügt dem üblichen Abhängigkeitsproblem ein paar Anforderungen hinzu:

  • Authentifizierung erforderlich. Nicht jedes Tool soll jeder herunterladen können. Verschiedene Kunden haben je nach Lizenzumfang Zugriff auf unterschiedliche Tools.
  • Versionierung ist wichtig. Pipelines, die eine bestimmte Tool-Version voraussetzen, müssen auch nach einem neuen Release weiterhin funktionieren. Pipelines, die immer die neueste Version wollen, sollen sie automatisch bekommen.
  • Audit-Trail. Für kommerziell verteilte Tools möchten wir nachvollziehen können, wer was wann heruntergeladen hat.
  • Wartbarkeit. Eine Lösung, die bei jedem neuen Release manuelle Eingriffe erfordert, ist keine Lösung: sie verlagert das Problem nur.

Die naheliegende Antwort ist: Binaries auf einen Server legen und Download-Links herausgeben. Das funktioniert einmal. Es skaliert nicht, bietet keine Versionierung, und die “Authentifizierung” läuft am Ende auf Security through Obscurity oder ein geteiltes Passwort hinaus, das nie geändert wird.


Was wir für Windows-CI/CD-Tool-Verteilung geprüft haben

Container und Linux-native Tooling

Zunächst eine Einschränkung, die einen Großteil des modernen Paketmanagement-Ökosystems von vornherein ausschließt: TwinCAT läuft nur unter Windows. Die TwinCAT-Runtime, die Build-Toolchain, das Automation Interface: alles Windows-only. Das bedeutet: keine Linux-Agents, keine Docker-Container, kein apt, kein brew. Jede Lösung muss auf Windows-Build-Nodes laufen. Das schließt viele Optionen aus, die in einem rein softwaregetriebenen Umfeld naheliegend wären.

Chocolatey

Chocolatey ist der naheliegendste erste Gedanke für Windows-Softwareverteilung. Die Plattform ist ausgereift, weit verbreitet und hat gute CI/CD-Unterstützung.

Das Problem liegt im Hosting-Modell für private Pakete. Die öffentliche Chocolatey-Registry ist genau das: öffentlich. Für proprietäre Tools braucht man einen privaten Feed, typischerweise Nexus, ProGet oder Chocolatey for Business. Das ist echte Serverinfrastruktur, die betrieben, gewartet und abgesichert werden muss. Dazu kommt, dass jedes Release als .nupkg verpackt werden muss, was einen zusätzlichen Schritt im Release-Prozess bedeutet und eine weitere potenzielle Fehlerquelle ist.

Für eine Handvoll proprietärer Tools, die an einen überschaubaren Kundenkreis verteilt werden, war ein eigener Chocolatey-Server klar überdimensioniert. Der Betriebsaufwand ist für Organisationen ausgelegt, die Hunderte von Paketen verwalten, nicht für ein kleines Produktteam mit fünf CLIs.

NuGet

NuGet ist für Code-Bibliotheken gedacht, nicht für ausführbare Tools. Man kann es für Letzteres zweckentfremden, aber die Ergonomie ist schlecht, und das Tooling setzt einen .NET-Entwicklungskontext voraus, der auf den meisten CI-Nodes nicht vorhanden ist. Schnell aussortiert.

winget

winget ist Microsofts offizieller Paketmanager, wird mit Windows 10 und 11 ausgeliefert und unterstützt auch private Package-Sources. Auf dem Papier die naheliegendste Antwort.

Der Haken liegt darin, was “private Source” in der Praxis bedeutet. Das winget-Modell für private Quellen setzt einen Server voraus, der Microsofts REST-Source-Spezifikation implementiert. Das ist mehr Infrastruktur als ein Chocolatey-Feed, nicht weniger, und das Ökosystem rund um selbst gehostete winget-Sources ist noch überschaubar. Dazu hat winget in nicht-interaktiven CI-Umgebungen noch Ecken und Kanten, die Chocolatey und Scoop längst abgeschliffen haben. Vielleicht ist es in ein paar Jahren die richtige Antwort. Im Moment bringt es Komplexität ohne zusätzlichen Nutzen.

Konfigurationsmanagement-Tools

Ansible, Puppet, Chef, Salt: Wer damit bereits seinen Build-Node-Pool verwaltet, kann damit auch sicherstellen, dass “Tool X in Version Y vorhanden ist”. Diese Tools sind genau für solche Zustandsverwaltung gebaut.

Die Einschränkung steckt in diesem Satz: “wer damit bereits arbeitet”. Für solche Teams ist es eine valide Option. Für Teams ohne bestehende Konfigurationsmanagement-Infrastruktur bedeutet es, ein vollständiges System einzuführen, nur um ein Tool-Verteilungsproblem zu lösen. Unsere Kunden arbeiten mit sehr unterschiedlichen Setups, und die meisten haben Ansible nicht im Einsatz.

Eigene Download-Skripte

So etwas hatten wir bereits, und wenn man ehrlich hinschaut, war es ein halbfertiger Paketmanager. Ein PowerShell-Skript in jeder Pipeline, das die richtige Binary per SFTP vom eigenen Server lädt, sie an die richtige Stelle legt und aufrufbar macht. Im Kern ist das genau das, was ein Paketmanager tut.

Die Schwierigkeit liegt in allem, was außerhalb des Normalfalls passiert. Wie verteilt man das Skript selbst? Bei einer Änderung mussten alle Pipelines separat angepasst werden. Versionierung war möglich, aber manuell: Pipelines, die eine bestimmte Tool-Version benötigten, mussten sie explizit anfordern, ohne dass sichergestellt war, dass diese Version noch verfügbar oder nicht längst überholt war. Sonderfälle häuften sich: Was, wenn die Binary bereits heruntergeladen ist? Was, wenn der SFTP-Server nicht erreichbar ist? Jeder dieser Fälle brauchte eine eigene Behandlung, und das Skript wurde immer größer.

Dazu war das Ganze vollständig unsichtbar: kein Log darüber, wer was heruntergeladen hatte, kein Überblick darüber, ob ein Kunde noch eine veraltete Tool-Version aktiv nutzte, keine Nachvollziehbarkeit.


Warum Scoop

Scoop ist vor allem als Tool für Entwickler-Workstations bekannt: eine bequeme Möglichkeit, git, python oder ffmpeg zu installieren, ohne einen Installer-Dialog durchzuklicken. Was weniger offensichtlich ist: Scoop läuft genauso gut in nicht-interaktiven CI-Umgebungen. Keine UAC-Dialoge, standardmäßig keine Administratorrechte, keine Bestätigungsabfragen. Es ist PowerShell-nativ, und CI-Nodes sind im Grunde nur Windows-Maschinen, die unbeaufsichtigt laufen. Wer diesen Gedanken einmal vollzogen hat, merkt, dass Scoop für die Verwaltung von CI/CD-Abhängigkeiten auf Windows-Nodes erstaunlich gut passt.

Die Kernidee: Ein “Bucket” ist nur ein Git-Repository mit JSON-Manifest-Dateien. Jedes Manifest beschreibt ein Paket: aktuelle Version, Download-URL, Hash und wie es aktualisiert wird. Scoop liest die Manifeste, lädt die Binary, prüft den Hash und legt sie an der richtigen Stelle ab. Keine zentrale Registry, kein spezieller Server, kein eigenes Paketformat. Ein Bucket sind Dateien in einem Git-Repository.

Das hat mehrere Vorteile.

Keine Infrastruktur zu betreiben. Der Bucket ist ein Git-Repository (GitHub, GitLab, eine selbst gehostete Gitea-Instanz, was auch immer bereits vorhanden ist). Es muss nichts Neues aufgesetzt werden, und es gibt nichts zu warten außer den JSON-Dateien im Bucket selbst.

Versionierung ist eingebaut. Scoop nutzt die Git-Historie, um bestimmte Versionen einzufrieren. Ein Kunde, der zkmake 1.9.0 braucht, kann genau diese Version installieren und festhalten. Ein Kunde, der immer die neueste Version möchte, führt scoop update aus und bekommt sie. Beide Fälle werden nativ abgedeckt.

Authentifizierung passt zu unserem Modell. Scoop hat eine private_hosts-Konfiguration, mit der man HTTP-Header für Anfragen an eine bestimmte Domain mitschicken kann. In der Praxis: Jeder Kunde bekommt einen Token von uns, richtet ihn einmalig auf seinen CI-Nodes ein, und Scoop sendet ihn bei jedem Download automatisch mit. Keine Zugangsdaten im Manifest, kein geteiltes Passwort, keine Umgebungsvariablen in Pipeline-Skripten.

Download-Caching. Scoop hält einen lokalen Cache der heruntergeladenen Binaries vor. Im CI-Kontext ist das relevant: Wenn ein Tool aus einem früheren Lauf bereits gecacht ist, überspringt Scoop den Download vollständig. Für Teams mit vielen Builds pro Tag auf demselben Node entfällt damit unnötiger Traffic zu unserer API, und die Build-Zeiten bleiben stabil, unabhängig von der Netzwerkauslastung.


Die Authentifizierungsschicht

Scoop übernimmt das Anhängen des Tokens an Download-Anfragen. Die andere Seite davon ist unsere Download-API, die den Token prüft, feststellt, ob dieser Client das angeforderte Tool herunterladen darf, die Binary überträgt und für jede Anfrage einen Audit-Log-Eintrag schreibt.

Jeder Kunde erhält einen eigenen Token, der an sein Abonnement gebunden ist, er wird beim Onboarding bereitgestellt oder kann jederzeit über unser Kontaktformular angefordert werden. Der Token ist eingeschränkt: Er gewährt nur Zugriff auf die lizenzierten Tools. Wird ein Token kompromittiert, tauschen wir ihn für diesen Kunden aus, ohne dass andere davon betroffen sind. Der Audit-Log liefert eine vollständige Aufzeichnung aller Downloads: wann, von wem und welche Version.

Auch der Versions-Endpunkt, den Scoop nutzt, um neue Releases zu erkennen, ist auf dieselbe Weise abgesichert. Selbst die Information darüber, welche Version aktuell ist, ist nicht öffentlich zugänglich.


Wie alles zusammenspielt

Das Gesamtbild hat drei Bausteine:

Der Bucket. Ein Git-Repository mit einem JSON-Manifest pro Tool. Jedes Manifest enthält die aktuelle Version, die Download-URL und den SHA256-Hash der Binary. Wenn wir eine neue Version veröffentlichen, aktualisiert unsere Jenkins-Release-Pipeline das Manifest automatisch, committet und pusht. Kein manueller Schritt, kein Risiko, etwas zu vergessen.

Die Download-API. Ein schlankes Backend, das Authentifizierung, Autorisierung und Logging übernimmt. Anfragen von Scoop gehen an diese API; wenn der Token gültig und der Client berechtigt ist, wird die Datei ausgeliefert. Andernfalls wird die Anfrage abgelehnt, bevor irgendetwas übertragen wird.

Node-Einrichtung. Ein Housekeeping-Job auf jedem Node übernimmt das automatisch, was der im Detail macht, folgt weiter unten.


Was gut funktioniert hat und was nicht

private_hosts verwenden, nicht download_headers. Wer mit Scoop authentifizierte Downloads implementieren möchte, findet hier den nützlichsten Hinweis aus diesem Beitrag. Scoop unterstützt ein download_headers-Feld direkt in der Manifest-JSON, was wie die naheliegende Stelle für Zugangsdaten wirkt: Das Manifest beschreibt, wie ein Paket heruntergeladen wird, also sollte es auch die Authentifizierung beschreiben. Genau das hat unsere erste Implementierung gemacht. Damit landen Zugangsdaten aber in einer Datei, die in einem Git-Repository liegt. Auch in einem privaten Repo ist das das falsche Modell. Scoops private_hosts-Konfiguration ist der richtige Weg: Sie hängt HTTP-Header an alle Anfragen an eine bestimmte Domain, einmalig auf dem Node konfiguriert, vollständig außerhalb des Manifests. Die Manifeste bleiben sauber und zugangsdatenfrei, und ein Token-Wechsel bedeutet eine Änderung an der Node-Konfiguration, nicht an einer Datei in der Versionsverwaltung. Die Funktion ist nicht besonders prominent dokumentiert, aber sie ist der korrekte Ansatz für jeden authentifizierten Scoop-Bucket.

certutil-Ausgabe ist sprachabhängig. Wir berechnen den SHA256-Hash jeder Binary im Rahmen der Release-Pipeline mit dem Windows-eigenen certutil-Tool und schreiben ihn ins Manifest. Auf einem englischsprachigen Windows-System gibt certutil -hashfile file.tar.gz SHA256 eine Kopfzeile “SHA256 Hash of file:” aus, gefolgt vom Hex-String. Auf einem deutschsprachigen System lautet dieselbe Zeile “SHA256-Hashvon…”, was unser Filter nicht abgefangen hat, sodass der deutsche Headertext im Hash-Wert im Manifest landete. Scoop lehnte die Binary daraufhin ab, weil der Hash nicht stimmte. Die Lösung war trivial (Groß-/Kleinschreibung beim Filtern ignorieren), aber das Problem trat nur auf deutschsprachigen Build-Nodes auf. Wer CI auf anderen Systemsprachen als Englisch betreibt, sollte das im Hinterkopf behalten.

Was wir von Anfang an richtig gemacht haben: Den Bucket sauber getrennt zu halten. Der Bucket enthält nur Daten: keine Secrets, keine besonderen Berechtigungen, und er lässt sich inspizieren oder auditieren, ohne den Rest des Systems anzufassen. Diese Trennung macht das Gesamtsystem leicht nachvollziehbar und einfach erweiterbar.


Wie eine TwinCAT-CI/CD-Pipeline in der Praxis aussieht

Auf der Node-Seite gibt es zwei Bestandteile. Der erste ist ein Housekeeping-Job, der läuft, wenn ein neuer Node in Betrieb geht, und einmal pro Nacht auf bestehenden Nodes. Er stellt sicher, dass Scoop installiert ist, richtet den Authentifizierungs-Token aus dem Credential-Store ein, fügt den Zeugwerk-Bucket hinzu und installiert oder aktualisiert die Tools. Der Job ist idempotent: Wird er auf einem bereits korrekt eingerichteten Node ausgeführt, passiert nichts. Ein neu aufgesetzter Node konvergiert dadurch von selbst in den richtigen Zustand, ganz ohne manuelle Eingriffe.

Der Kern dieses Housekeeping-Skripts sieht in etwa so aus (der Bucket liegt unter github.com/Zeugwerk/scoop-bucket). $Token ist der kundeneigene Bearer-Token, den wir beim Onboarding bereitstellen; in der Praxis kommt er aus dem Credential-Store des CI-Systems und wird nicht fest eingetragen:

# 1. Install Scoop if not already present
if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) {
   Invoke-RestMethod get.scoop.sh | Invoke-Expression
}
# 2. Register the token with Scoop's private_hosts config.
#    This tells Scoop to attach an Authorization header to every
#    download request that matches https://api.zeugwerk.dev/*.
#    The result is that authentication happens automatically,
#    without any credentials appearing in pipeline scripts.
$configFile = "$HOME\.config\scoop\config.json"
New-Item (Split-Path $configFile) -ItemType Directory -Force | Out-Null
$config = if (Test-Path $configFile) {
   Get-Content $configFile -Raw | ConvertFrom-Json
} else {
   [PSCustomObject]@{}
}
$entry = [PSCustomObject]@{
   match   = "https://api.zeugwerk.dev/*"
   headers = "Authorization=Bearer $Token"
}
# Remove any existing entry for the same domain, then add the new one
$newHosts = @($config.private_hosts | Where-Object { $_ -and $_.match -ne $entry.match }) + $entry
# Add-Member -Force creates the private_hosts property if it doesn't exist yet
# (fresh config, or a config.json Scoop wrote before private_hosts was ever set),
# or overwrites it if it's already there.
$config | Add-Member -MemberType NoteProperty -Name private_hosts -Value $newHosts -Force
$config | ConvertTo-Json -Depth 10 | Set-Content $configFile
# 3. Add the Zeugwerk bucket and install the tools
scoop install git
scoop bucket add zeugwerk https://github.com/Zeugwerk/scoop-bucket
scoop install zkmake zkdoc zkinstall

Der zweite Bestandteil ist die Pipeline selbst, die sich kaum noch um Tools kümmern muss. So sieht ein minimaler GitLab-CI-Job bei einem Kunden mit eigenem Windows-Runner mit TwinCAT aus:

build:
  tags: [windows]
  script:
    # Tools sind per einmaligem Setup auf dem Node vorinstalliert
    - scoop update zkmake zkdoc
    - zkmake build --update-snapshots --kill-all
    - zkdoc --docfx reference --output documentation .
  artifacts:
    paths: ["*.library", "documentation/"]

Mehr ist es nicht. Keine Download-Logik, keine Zugangsdatenverwaltung in der Pipeline, keine fest einprogrammierten Pfade. Scoop löst die Version auf, übernimmt die Authentifizierung gegenüber unserer API im Hintergrund und greift auf den lokalen Cache zurück, wenn sich die Binary nicht geändert hat. Die Pipeline bleibt übersichtlich, und die Tool-Verwaltung bleibt dort, wo sie hingehört, außerhalb der Pipeline-Definition.


Was sich damit verändert hat

Vorher bedeutete das Einrichten eines neuen Jenkins-Nodes, dass jemand einen manuellen Prozess abarbeiten musste: die richtige Binary herunterladen, an die richtige Stelle legen, die Pipeline-Konfiguration anpassen. Reproduzierbarkeit hing davon ab, dass derjenige alle Schritte im Kopf hatte.

Für Kunden, die unsere Tools in eigenen Pipelines nutzen, beschränkt sich das Onboarding auf ein kurzes Skript und einen Token. Für uns bedeutet ein neues Release ein Commit ins Manifest, die Verteilung erledigt sich von selbst.

Der Ansatz ist nicht TwinCAT-spezifisch. Wer Windows-CLI-Tools verteilt, ob kommerziell oder intern, kann dasselbe Muster verwenden: ein privates Git-Repository als Bucket, eine schlanke authentifizierte Download-API vor den Binaries, und Scoop für Versionierung und Caching auf der Client-Seite. Die Bausteine passen für jede Situation, in der man kontrollierte, versionierte Verteilung auf Windows-CI-Nodes braucht.


Fragen zu unserer DevTool-Verteilung, oder Interesse daran, ob so ein Setup auch für eure Infrastruktur passt? Einfach melden.