Building Reusable Bash Libraries for CI Pipelines
AI generated
$_
#!/
Bash · CI/CD · Libraries · BATS
Reusable Bash Libraries for CI Pipelines
Build logging, retry and notifications once, include them everywhere

Rewriting the same Bash functions for logging, retry logic and notifications in every repository creates maintenance overhead a single, shared library file avoids. Built, versioned and tested properly, such a Bash library becomes the stable foundation for every CI pipeline on a team, instead of the next source of copy-paste bugs.

17 min read lib.sh · sourcing guard Git tags · BATS tests

1. Why shared Bash functions make sense in CI pipelines

As soon as a team maintains more than one or two repositories with their own CI pipelines, the same small Bash helpers keep reappearing: a function that retries a failed network operation with backoff, one that sends a Slack message on a failed deployment, and one that formats log lines consistently with a timestamp and log level. If every one of these functions gets rewritten independently in every repository, the implementations drift apart over time, and a bugfix in one repository never reaches the others.

A shared Bash library solves this problem by maintaining these functions in exactly one place and having every pipeline include it. The effort of structuring, versioning and testing such a library properly pays off starting from a handful of repositories, because every improvement, every bugfix and every new feature becomes available to all pipelines the moment they update the library.

2. Structuring a library file: only functions, one sourcing guard

A Bash library differs structurally from a normal script in that it contains exclusively function definitions and is never executed directly, but included in other scripts via source. That means a library file must not contain top-level statements that trigger side effects immediately upon being sourced, such as a network call or setting global variables that could surprise a calling script.

One important detail missing from many self-built libraries is a sourcing guard: a check at the top of the file that prevents the same library from accidentally being sourced twice, for instance because two different scripts that both need the library call each other. Without this guard, functions get defined twice, which is usually harmless in Bash but can lead to hard-to-diagnose bugs in more complex libraries carrying global state.


#!/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 functions: consistent output across every pipeline

The most frequently reused function in any CI library is unified logging. Instead of formatting echo calls individually in every script, a function like log_info or log_error encapsulates the format of timestamp, log level and message in one place, so a later format change, such as switching to structured JSON logging for an observability tool, only needs to happen in a single spot.

Colored output is pleasant in a local terminal but can turn into unreadable character noise in CI log viewers that do not interpret ANSI color codes. A robust logging function therefore checks whether output is going to a terminal ([[ -t 1 ]]) and only enables colors then, instead of writing them unconditionally into every environment.


#!/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. A retry function with backoff for flaky network operations

Network operations in CI pipelines, such as downloading dependencies or pushing an image to a registry, occasionally fail due to brief network hiccups, even though a second attempt shortly after would succeed. A retry function with exponential backoff encapsulates this retry logic once, centrally, instead of every script rebuilding it individually and usually incompletely.

It matters that the retry function propagates the original exit code of the last failed attempt instead of swallowing it, so a calling script can still react correctly after a final failure. Just as important is an upper bound on attempts, so a permanently broken service does not block the entire pipeline indefinitely.


#!/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 functions: surfacing failures without slowing the pipeline

A notification function that sends a message to a Slack or Teams webhook on a failed deployment is one of the most useful, but also one of the most frequently mis-built library functions. The most common mistake is firing the curl call to the webhook without its own error handling, so a network problem while sending the notification itself crashes the entire pipeline, even though the actual problem lies elsewhere entirely.

A robust notification function therefore treats sending as a best-effort operation: it attempts to send the message but only logs a warning and lets the pipeline continue unaffected if even the send itself fails. A timeout on the curl call additionally prevents a hanging webhook connection from needlessly slowing down the entire pipeline.


#!/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. Including the library across multiple repositories: submodule, download, or package

Once the library is meant to be used in several repositories, the question becomes how it gets there. A Git submodule includes the library repository as a subdirectory and allows pinning a fixed commit reference, but has the downside that submodules in practice are often forgotten during updates and confuse newcomers.

The most pragmatic solution in CI environments is usually downloading the library file with curl at the start of the pipeline directly from a fixed, versioned URL, such as a GitHub release asset, and including it locally with source. This avoids submodules entirely, makes the version in use visible in the pipeline log, and works identically in every CI system without the target repository having to carry the library's history along.


#!/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. Versioning the library: Git tags, pinning, and breaking changes

A shared library used by many pipelines at once needs a clear versioning scheme, usually Semantic Versioning via Git tags. Every pipeline should pin a fixed version instead of always loading the latest version from a main branch, because otherwise a single breaking change in the library simultaneously breaks every pipeline that includes it, without any warning.

Breaking changes, such as a changed function signature or a renamed function, belong exclusively in a major version bump, accompanied by a migration note in the changelog. That way every team decides for itself when to move to a new major version, instead of being surprised by a silent change, while bugfixes still reach everyone quickly and with low risk via patch versions.

8. Testing the library itself: BATS tests for isolated functions

A library that numerous pipelines depend on must itself be tested before a new version is released. BATS (Bash Automated Testing System) is particularly well suited for this because it runs Bash functions in isolated subshells, preventing a test from being influenced by side effects of a previous test.

For functions with external dependencies, such as notify_slack, which would trigger a real network call, a test temporarily replaces the curl function with its own shell function of the same name that merely logs its arguments. This technique of mocking functions instead of real binaries makes the library's own tests fast, deterministic, and independent of real network resources.


#!/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. Comparing inclusion methods

Which inclusion method fits a shared Bash library best depends on the existing CI system, team size, and how strictly versions need to be pinned. The overview below summarizes the common options.

Method Version pinning Complexity Typical use
Git submodule Fixed via commit SHA High, updates often forgotten Teams close to a monorepo with Git experience
curl download from a release URL Fixed via version tag in the URL Low, CI-system independent Most CI pipelines
Container image with the library pre-installed Fixed via image tag Medium, needs a custom base image Docker-based CI runners
Copy-paste per repository No pinning, kept in sync manually Low at first, high long term Only as a stopgap

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

Bash Libraries for CI: The Essentials at a Glance

Structure

A library file contains only function definitions, a sourcing guard, and no top-level side effects.

Core functions

Logging with terminal detection, retry with exponential backoff, and best-effort notifications are the most common building blocks.

Inclusion

A curl download of a versioned release URL at pipeline start is more pragmatic than Git submodules.

Testing

BATS tests library functions in isolated subshells; external calls like curl get mocked with shell functions.

11. FAQ: Bash Libraries for CI: The Essentials at a Glance

1What is a sourcing guard and why do I need one?
A sourcing guard is a check at the top of the library file that prevents the same library from accidentally being included multiple times. It avoids duplicate function definitions and unexpected behavior in more complex libraries carrying global state.
2Why should a library file never be executed directly?
A library contains only function definitions with no value when executed directly. A guard comparing BASH_SOURCE with $0 can detect that and print a helpful error message instead of silently doing nothing.
3How do I stop colored logs from looking broken in CI log viewers?
With a check like [[ -t 1 ]] that detects whether standard output goes to a real terminal. Only then are ANSI color codes inserted; in CI log files the output stays plain text.
4How do I build a retry function with exponential backoff?
A loop repeats the command up to a maximum number of attempts, doubles the wait time between attempts, and finally propagates the last exit code of the failed command instead of swallowing it.
5Should a failed Slack webhook abort the pipeline?
No. Notifications should be treated as a best-effort operation: a send failure gets logged, but the actual pipeline continues unaffected.
6How do I include a shared library across multiple repositories?
The most pragmatic approach is a curl download of the library file from a fixed, versioned release URL at the start of every pipeline, followed by source. This works identically in every CI system without Git submodules.
7Why should I pin a fixed version instead of always loading main?
Without pinning, a single breaking change in the library simultaneously breaks every pipeline that includes it. With pinned versions, each team decides for itself when to update.
8How do I test Bash functions that make external network calls?
By temporarily replacing the external dependency, such as curl, with its own shell function of the same name that only logs its arguments. That keeps the test deterministic and independent of real network resources.
9What belongs in a major version bump of the library?
Every breaking change, such as a changed function signature or a renamed function, always accompanied by a migration note in the changelog, so teams can consciously decide when to move over.
10Are Git submodules a good choice for a CI library?
They work, but in practice often bring problems with forgotten updates and confusion for newcomers. A simple curl download of a versioned file is the more pragmatic choice for most CI pipelines.