Writing Portable CI Helper Scripts
The most maintainable CI configuration keeps provider YAML focused on orchestration and puts reusable logic in versioned scripts. Those scripts should be runnable locally and portable across the environments you deliberately support.
Learning objectives
By the end of this lesson
- Move reusable CI logic into versioned Bash helpers.
- Resolve repository paths from the script itself.
- Normalize provider-specific variables at the boundary.
- Avoid unnecessary GNU/BSD portability traps.
- Test helpers outside CI and lint them consistently.
1. Put reusable logic in versioned scripts, not giant YAML strings
CI configuration should describe orchestration while Bash helper scripts implement reusable command logic. Versioned scripts are easier to run locally, lint, test, review, and reuse across CI providers.
flowchart LR Y["CI YAML"] --> H["scripts/ci/*.sh"] H --> T["tool CLIs"] H --> A["artifacts / outputs"] H --> E["exit status"]
2. Declare Bash explicitly
#!/usr/bin/env bash
set -u
set -o pipefailIf the helper uses Bash arrays, [[ ]], process substitution, or other Bash features, do not label it as POSIX sh.
3. Resolve paths from the script, not the caller
script_dir=$(
cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &&
pwd -P
) || exit 1
repo_root=$(
cd -- "$script_dir/../.." &&
pwd -P
) || exit 1
cd -- "$repo_root" || exit 14. Check dependencies at startup
require_command() {
command -v "$1" >/dev/null 2>&1 || {
printf 'required command missing: %s\n' "$1" >&2
return 69
}
}
require_command git || exit $?
require_command jq || exit $?A helper should fail with an actionable message rather than later producing a confusing “command not found.”
5. Normalize CI-provider variables into your own interface
commit_sha=${CI_COMMIT_SHA:-${GITHUB_SHA:-${BUILD_SOURCEVERSION:-}}}
[[ -n $commit_sha ]] || {
commit_sha=$(git rev-parse HEAD)
}
printf 'commit_sha=%s\n' "$commit_sha"Provider-specific variables should be translated near the boundary. Internal functions should work with your normalized names instead of knowing every provider.
6. Keep outputs provider-neutral when possible
printf '%s\n' "$artifact_path"A helper can emit a simple stdout value or write a neutral file. A thin provider adapter can then publish that value using the CI system's specific output mechanism.
7. Use portable temporary-state patterns
tmpdir=$(mktemp -d) || exit 1
cleanup() {
local status=$?
rm -rf -- "$tmpdir"
exit "$status"
}
trap cleanup EXITTest mktemp behavior on every supported OS, especially when custom templates are involved.
8. Avoid unnecessary GNU-only flags in cross-platform helpers
9. Quote every path and test spaces in workspace names
artifact_dir="$repo_root/build artifacts"
mkdir -p -- "$artifact_dir"
cp -- "$source" "$artifact_dir/"CI workspaces can contain unexpected path components. A helper that only works in /tmp/repo is not robust.
10. Define what Windows support means
Bash on Linux, macOS, WSL, and Git Bash share much syntax but differ in filesystem paths, executable discovery, permissions, signals, and installed Unix tools. A portable helper needs a documented platform matrix rather than a vague claim of “works everywhere.”
11. Test CI helpers outside CI
env -i \
HOME="$HOME" \
PATH="$PATH" \
CI=true \
bash --noprofile --norc \
scripts/ci/build.shA local simulation catches hidden profile dependencies and missing environment assumptions before the pipeline becomes the debugging environment.
12. ShellCheck and shfmt belong in the helper workflow
shellcheck scripts/ci/*.sh
shfmt -d scripts/ci/*.shStatic analysis and formatting make reusable helper scripts easier to maintain. Chapter 17 will cover these tools in depth.
13. Hands-on lab: portable CI helper skeleton
mkdir -p "$HOME/devops-academy/bash/chapter16/lesson05/scripts/ci"
cd "$HOME/devops-academy/bash/chapter16/lesson05"
cat > scripts/ci/build-info.sh <<'EOF'
#!/usr/bin/env bash
set -u
set -o pipefail
script_dir=$(
cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &&
pwd -P
) || exit 1
repo_root=$(
cd -- "$script_dir/../.." &&
pwd -P
) || exit 1
cd -- "$repo_root" || exit 1
command -v git >/dev/null 2>&1 || {
printf 'git is required\n' >&2
exit 69
}
commit_sha=${CI_COMMIT_SHA:-${GITHUB_SHA:-}}
if [[ -z $commit_sha ]]; then
commit_sha=$(git rev-parse HEAD 2>/dev/null || printf unknown)
fi
printf 'repo_root=%s\n' "$repo_root"
printf 'commit_sha=%s\n' "$commit_sha"
printf 'ci=%s\n' "${CI:-false}"
EOF
chmod u+x scripts/ci/build-info.sh
CI=true bash --noprofile --norc scripts/ci/build-info.shVerification checklist
14. Knowledge check
Question 1. Why move reusable CI logic into repository scripts?
Question 2. Where should provider-specific variable names be translated?
Question 3. Why test workspace paths containing spaces?
Question 4. What is required before calling a helper cross-platform?
15. Summary
Portable CI helpers declare their shell, resolve paths relative to themselves, normalize provider-specific inputs, check dependencies, avoid gratuitous platform-specific flags, quote paths rigorously, and remain runnable outside the CI system. Keep CI YAML thin and the reusable logic versioned beside the code.
16. Further reading
- GNU Bash Reference Manual — invocation, parameters, arrays, and quoting.
- ShellCheck documentation.
- shfmt documentation.
- CI provider documentation for environment variables and runner operating systems.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.