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.
Table of Contents
- 1. Why shared Bash functions make sense in CI pipelines
- 2. Structuring a library file: only functions, one sourcing guard
- 3. Logging functions: consistent output across every pipeline
- 4. A retry function with backoff for flaky network operations
- 5. Notification functions: surfacing failures without slowing the pipeline
- 6. Including the library across multiple repositories: submodule, download, or package
- 7. Versioning the library: Git tags, pinning, and breaking changes
- 8. Testing the library itself: BATS tests for isolated functions
- 9. Comparing inclusion methods
- 10. Summary
- 11. FAQ
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.