Wiederverwendbare Bash-Bibliotheken für CI-Pipelines bauen
AI generated
$_
#!/
Bash · CI/CD · Bibliotheken · BATS
Wiederverwendbare Bash-Bibliotheken für CI-Pipelines
Logging, Retry und Notifications einmal bauen, überall einbinden

Wer dieselben Bash-Funktionen für Logging, Retry-Logik und Benachrichtigungen in jedem Repository neu schreibt, produziert Wartungsaufwand, den eine einzige, geteilte Library-Datei vermeidet. Richtig gebaut, versioniert und getestet wird eine solche Bash-Bibliothek zum stabilen Fundament für alle CI-Pipelines eines Teams, statt zur nächsten Quelle für Copy-Paste-Bugs.

17 Min. Lesezeit lib.sh · Sourcing-Guard Git-Tags · BATS-Tests

1. Warum gemeinsame Bash-Funktionen in CI-Pipelines Sinn ergeben

Sobald ein Team mehr als ein oder zwei Repositories mit eigenen CI-Pipelines pflegt, tauchen dieselben kleinen Bash-Helfer immer wieder auf: eine Funktion, die eine fehlgeschlagene Netzwerkoperation mit Backoff wiederholt, eine, die eine Slack-Nachricht bei einem fehlgeschlagenen Deployment verschickt, und eine, die Log-Zeilen konsistent mit Zeitstempel und Log-Level formatiert. Wird jede dieser Funktionen in jedem Repository unabhängig neu geschrieben, driften die Implementierungen mit der Zeit auseinander, und ein Bugfix in einem Repository erreicht die anderen nie.

Eine gemeinsame Bash-Bibliothek löst dieses Problem, indem sie diese Funktionen an genau einer Stelle pflegt und von allen Pipelines eingebunden wird. Der Aufwand, eine solche Library sauber zu strukturieren, zu versionieren und zu testen, zahlt sich schon ab einer Handvoll Repositories aus, weil jede Verbesserung, jeder Bugfix und jedes neue Feature sofort allen Pipelines zur Verfügung steht, sobald sie die Library aktualisieren.

2. Aufbau einer Library-Datei: nur Funktionen, ein Sourcing-Guard

Eine Bash-Library unterscheidet sich strukturell von einem normalen Skript dadurch, dass sie ausschließlich Funktionsdefinitionen enthält und niemals direkt ausgeführt wird, sondern per source in andere Skripte eingebunden wird. Deshalb darf eine Library-Datei keine Top-Level-Anweisungen enthalten, die beim Einbinden sofort Nebenwirkungen auslösen, etwa einen Netzwerkaufruf oder das Setzen globaler Variablen, die ein aufrufendes Skript überraschen könnten.

Ein wichtiges Detail, das in vielen selbstgebauten Libraries fehlt, ist ein Sourcing-Guard: eine Prüfung am Dateianfang, die verhindert, dass dieselbe Library versehentlich zweimal eingebunden wird, etwa weil zwei verschiedene Skripte, die beide dieselbe Library brauchen, sich gegenseitig aufrufen. Ohne diesen Guard werden Funktionen doppelt definiert, was zwar in Bash meist harmlos ist, aber bei komplexeren Libraries mit globalen Zustandsvariablen zu schwer nachvollziehbaren Fehlern führen kann.


#!/usr/bin/env bash
# lib.sh -- shared CI functions. Source this, never execute it directly.

# Sourcing guard: skip re-definition if this file was already loaded
if [[ -n "${MIRONSOFT_LIB_LOADED:-}" ]]; then
  return 0
fi
readonly MIRONSOFT_LIB_LOADED=1

# Fail loudly if someone tries to execute this file instead of sourcing it
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  echo "lib.sh is a library, source it: source lib.sh" >&2
  exit 1
fi

3. Logging-Funktionen: konsistente Ausgabe über alle Pipelines hinweg

Die am häufigsten wiederverwendete Funktion in jeder CI-Library ist ein einheitliches Logging. Statt in jedem Skript individuell echo-Aufrufe zu formatieren, kapselt eine Funktion wie log_info oder log_error das Format aus Zeitstempel, Log-Level und Nachricht an einer Stelle, sodass eine spätere Änderung des Formats, etwa der Wechsel zu strukturiertem JSON-Logging für ein Observability-Tool, nur an einer einzigen Stelle passieren muss.

Farbige Ausgabe ist im lokalen Terminal angenehm, kann aber in CI-Log-Viewern, die keine ANSI-Farbcodes interpretieren, zu unlesbarem Zeichenmüll führen. Eine robuste Logging-Funktion prüft deshalb, ob die Ausgabe an ein Terminal geht ([[ -t 1 ]]), und schaltet Farben nur dann ein, statt sie bedingungslos in jede Umgebung zu schreiben.


#!/usr/bin/env bash

_log() {
  local level="$1" color="$2"; shift 2
  local reset="" prefix=""
  if [[ -t 1 ]]; then
    reset=$'\033[0m'
    prefix="${color}"
  fi
  printf '%s[%s] %s%s %s\n' "$prefix" "$level" "$(date -u +%H:%M:%S)" "$reset" "$*"
}

log_info()  { _log "INFO"  $'\033[36m' "$@"; }
log_warn()  { _log "WARN"  $'\033[33m' "$@"; }
log_error() { _log "ERROR" $'\033[31m' "$@" >&2; }

4. Eine Retry-Funktion mit Backoff für instabile Netzwerkoperationen

Netzwerkoperationen in CI-Pipelines, etwa das Herunterladen von Dependencies oder das Pushen eines Images in eine Registry, schlagen gelegentlich wegen kurzzeitiger Netzwerkprobleme fehl, obwohl ein zweiter Versuch kurz danach funktionieren würde. Eine Retry-Funktion mit exponentiellem Backoff kapselt diese Wiederholungslogik einmal zentral, statt sie in jedem Skript individuell und meist unvollständig nachzubauen.

Wichtig ist, dass die Retry-Funktion den ursprünglichen Exit-Code des letzten fehlgeschlagenen Versuchs weiterreicht, statt ihn zu verschlucken, damit ein aufrufendes Skript nach endgültigem Fehlschlag weiterhin korrekt reagieren kann. Ebenso wichtig ist eine Obergrenze an Versuchen, damit ein dauerhaft kaputter Dienst nicht die gesamte Pipeline unbegrenzt blockiert.


#!/usr/bin/env bash

retry() {
  local max_attempts="$1" delay="$2"; shift 2
  local attempt=1
  until "$@"; do
    local exit_code=$?
    if (( attempt >= max_attempts )); then
      log_error "Command failed after $attempt attempts: $*"
      return "$exit_code"
    fi
    log_warn "Attempt $attempt/$max_attempts failed, retrying in ${delay}s: $*"
    sleep "$delay"
    ((attempt++))
    ((delay *= 2))
  done
}

# Usage: retry 5 2 curl --fail -o artifact.tar.gz "$ARTIFACT_URL"

5. Notification-Funktionen: Fehler sichtbar machen, ohne die Pipeline zu bremsen

Eine Notification-Funktion, die bei einem fehlgeschlagenen Deployment eine Nachricht an einen Slack- oder Teams-Webhook schickt, gehört zu den nützlichsten, aber auch am häufigsten falsch gebauten Bibliotheksfunktionen. Der häufigste Fehler ist, den curl-Aufruf an den Webhook ohne eigene Fehlerbehandlung abzusetzen, sodass ein Netzwerkproblem beim Versenden der Benachrichtigung selbst die gesamte Pipeline zum Absturz bringt, obwohl das eigentliche Problem längst woanders liegt.

Eine robuste Notification-Funktion behandelt den Versand deshalb als Best-Effort-Operation: Sie versucht die Nachricht zu senden, protokolliert aber nur eine Warnung und lässt die Pipeline unbeeinflusst weiterlaufen, wenn selbst der Versand fehlschlägt. Ein Timeout auf dem curl-Aufruf verhindert zusätzlich, dass eine hängende Webhook-Verbindung die gesamte Pipeline unnötig verlangsamt.


#!/usr/bin/env bash

notify_slack() {
  local message="$1" webhook_url="${SLACK_WEBHOOK_URL:-}"
  [[ -z "$webhook_url" ]] && { log_warn "No SLACK_WEBHOOK_URL set, skipping notification"; return 0; }

  local payload
  payload="$(printf '{"text":"%s"}' "$message")"

  if ! curl --fail --silent --max-time 5 -X POST -H 'Content-Type: application/json' \
       -d "$payload" "$webhook_url" >/dev/null 2>&1; then
    log_warn "Failed to send Slack notification, continuing anyway"
  fi
}

6. Einbindung über mehrere Repositories hinweg: Submodule, Download oder Paket

Sobald die Library in mehreren Repositories genutzt werden soll, stellt sich die Frage, wie sie dorthin kommt. Ein Git-Submodule bindet das Library-Repository als Unterverzeichnis ein und erlaubt es, eine feste Commit-Referenz zu pinnen, hat aber den Nachteil, dass Submodule in der Praxis oft vergessen werden zu aktualisieren und für Einsteiger verwirrend sind.

Die in CI-Umgebungen pragmatischste Lösung ist meist, die Library-Datei mit curl zu Beginn der Pipeline direkt von einer festen, versionierten URL herunterzuladen, etwa einem GitHub-Release-Asset, und lokal mit source einzubinden. Das vermeidet Submodule vollständig, macht die verwendete Version im Pipeline-Log sichtbar und funktioniert identisch in jedem CI-System, ohne dass das Ziel-Repository die Library-Historie mitschleppen muss.


#!/usr/bin/env bash
set -euo pipefail

readonly LIB_VERSION="v2.3.0"
readonly LIB_URL="https://github.com/mironsoft/ci-lib/releases/download/${LIB_VERSION}/lib.sh"

curl --fail --silent --show-error -o /tmp/lib.sh "$LIB_URL"
# shellcheck source=/dev/null
source /tmp/lib.sh

log_info "Loaded ci-lib ${LIB_VERSION}"

7. Versionierung der Library: Git-Tags, Pinning und Breaking Changes

Eine gemeinsame Library, die von vielen Pipelines gleichzeitig genutzt wird, braucht ein klares Versionierungsschema, meist Semantic Versioning über Git-Tags. Jede Pipeline sollte eine feste Version pinnen, statt immer die neueste Version von einem main-Branch zu laden, denn sonst bricht eine einzelne Breaking-Change-Änderung in der Library gleichzeitig jede Pipeline, die sie einbindet, ohne Vorwarnung.

Breaking Changes, etwa eine geänderte Funktionssignatur oder ein umbenannter Funktionsname, gehören ausschließlich in einen Major-Versionssprung, begleitet von einem Migrationshinweis im Changelog. So kann jedes Team selbst entscheiden, wann es auf eine neue Major-Version umsteigt, statt von einer stillen Änderung überrascht zu werden, während gleichzeitig Bugfixes über Patch-Versionen schnell und risikoarm überall ankommen.

8. Testbarkeit der Library selbst: BATS-Tests für Funktionen isoliert

Eine Bibliothek, die von zahlreichen Pipelines abhängt, muss selbst getestet werden, bevor eine neue Version veröffentlicht wird. BATS (Bash Automated Testing System) eignet sich dafür besonders gut, weil es Bash-Funktionen isoliert innerhalb eigener Subshells ausführt und damit verhindert, dass ein Test durch Nebeneffekte eines vorherigen Tests beeinflusst wird.

Für Funktionen mit externen Abhängigkeiten wie notify_slack, die einen echten Netzwerkaufruf auslösen würden, ersetzt ein Test die Funktion curl vorübergehend durch eine eigene Shell-Funktion gleichen Namens, die lediglich ihre Argumente protokolliert. Diese Technik, Funktionen statt echter Binaries zu mocken, macht die Tests der Library selbst schnell, deterministisch und unabhängig von echten Netzwerkressourcen.


#!/usr/bin/env bats
# lib.bats -- run with: bats lib.bats

setup() {
  source lib.sh
}

@test "log_info includes the INFO level in its output" {
  run log_info "hello"
  [[ "$output" == *"INFO"* ]]
  [[ "$output" == *"hello"* ]]
}

@test "retry succeeds without retrying if the command works first try" {
  run retry 3 1 true
  [[ "$status" -eq 0 ]]
}

@test "retry gives up and returns the last exit code after max attempts" {
  run retry 2 0 false
  [[ "$status" -ne 0 ]]
}

9. Einbindungsmethoden im Vergleich

Welche Einbindungsmethode für eine geteilte Bash-Library am besten passt, hängt vom bestehenden CI-System, der Teamgröße und davon ab, wie strikt Versionen gepinnt werden müssen. Die folgende Übersicht fasst die gängigen Optionen zusammen.

Methode Versions-Pinning Komplexität Typischer Einsatz
Git-Submodule Fest per Commit-SHA Hoch, oft vergessen zu aktualisieren Monorepo-nahe Teams mit Git-Erfahrung
curl-Download per Release-URL Fest per Versions-Tag in URL Niedrig, CI-System-unabhängig Die meisten CI-Pipelines
Container-Image mit vorinstallierter Library Fest per Image-Tag Mittel, braucht eigenes Base-Image Docker-basierte CI-Runner
Copy-Paste pro Repository Kein Pinning, manuell synchron gehalten Niedrig zu Beginn, hoch langfristig Nur als Übergangslösung

Mironsoft

Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur

Shell-Skripte, die in der Produktion zuverlässig laufen?

Wir analysieren bestehende Bash-Skripte, erkennen fragile Muster und ersetzen sie durch robuste Bash-Patterns: mit vollständiger Fehlerbehandlung, Logging und sicherer Parallelisierung für euren Deployment-Stack.

Code-Review

ShellCheck-Analyse und manuelle Prüfung auf kritische Bash-Pattern-Verstöße.

Refactoring

Fehlerbehandlung, Logging und sichere Dateioperationen nachrüsten.

CI-Integration

ShellCheck und BATS in Pipelines integrieren und Regressionstests aufbauen.

10. Zusammenfassung

Bash-Bibliotheken für CI: Das Wichtigste auf einen Blick

Struktur

Eine Library-Datei enthält nur Funktionsdefinitionen, einen Sourcing-Guard und keine Top-Level-Seiteneffekte.

Kernfunktionen

Logging mit Terminal-Erkennung, Retry mit exponentiellem Backoff und Best-Effort-Notifications sind die häufigsten Bausteine.

Einbindung

curl-Download einer versionierten Release-URL zu Pipeline-Beginn ist pragmatischer als Git-Submodule.

Testen

BATS testet Bibliotheksfunktionen isoliert in Subshells; externe Aufrufe wie curl werden durch Shell-Funktionen gemockt.

11. FAQ: Bash-Bibliotheken für CI: Das Wichtigste auf einen Blick

1Was ist ein Sourcing-Guard und warum brauche ich ihn?
Ein Sourcing-Guard ist eine Prüfung am Anfang der Library-Datei, die verhindert, dass dieselbe Library versehentlich mehrfach eingebunden wird. Er vermeidet doppelte Funktionsdefinitionen und unerwartetes Verhalten bei komplexeren Libraries mit globalem Zustand.
2Warum sollte eine Library-Datei niemals direkt ausgeführt werden?
Eine Library enthält nur Funktionsdefinitionen ohne eigenen Nutzen bei direkter Ausführung. Ein Guard, der BASH_SOURCE mit $0 vergleicht, kann das erkennen und eine hilfreiche Fehlermeldung ausgeben statt stillschweigend nichts zu tun.
3Wie verhindere ich, dass farbige Logs in CI-Log-Viewern kaputt aussehen?
Mit einer Prüfung wie [[ -t 1 ]], die feststellt, ob die Standardausgabe an ein echtes Terminal geht. Nur dann werden ANSI-Farbcodes eingefügt, in CI-Log-Dateien bleibt die Ausgabe reiner Text.
4Wie baue ich eine Retry-Funktion mit exponentiellem Backoff?
Eine Schleife wiederholt den Befehl bis zu einer maximalen Anzahl an Versuchen, verdoppelt die Wartezeit zwischen den Versuchen und reicht am Ende den letzten Exit-Code des fehlgeschlagenen Befehls weiter, statt ihn zu verschlucken.
5Sollte ein fehlgeschlagener Slack-Webhook die Pipeline abbrechen?
Nein. Notifications sollten als Best-Effort-Operation behandelt werden: Ein Fehlschlag beim Versenden wird protokolliert, aber die eigentliche Pipeline läuft unbeeinflusst weiter.
6Wie binde ich eine gemeinsame Library über mehrere Repositories hinweg ein?
Am pragmatischsten ist ein curl-Download der Library-Datei von einer festen, versionierten Release-URL zu Beginn jeder Pipeline, gefolgt von source. Das funktioniert identisch in jedem CI-System ohne Git-Submodule.
7Warum sollte ich eine feste Version pinnen statt immer main zu laden?
Ohne Pinning bricht eine einzelne Breaking-Change-Änderung in der Library gleichzeitig jede Pipeline, die sie einbindet. Mit gepinnten Versionen entscheidet jedes Team selbst, wann es aktualisiert.
8Wie teste ich Bash-Funktionen, die externe Netzwerkaufrufe machen?
Indem die externe Abhängigkeit wie curl vorübergehend durch eine eigene Shell-Funktion gleichen Namens ersetzt wird, die nur ihre Argumente protokolliert. So bleibt der Test deterministisch und unabhängig von echten Netzwerkressourcen.
9Was gehört in eine Major-Versionsänderung der Library?
Jede Breaking Change wie eine geänderte Funktionssignatur oder ein umbenannter Funktionsname, immer begleitet von einem Migrationshinweis im Changelog, damit Teams bewusst entscheiden können, wann sie umsteigen.
10Sind Git-Submodule eine gute Wahl für eine CI-Library?
Sie funktionieren, bringen aber in der Praxis oft Probleme mit vergessenen Updates und Verwirrung bei Einsteigern mit sich. Ein einfacher curl-Download einer versionierten Datei ist für die meisten CI-Pipelines die pragmatischere Wahl.