Chapter 16Lesson 05~110 minutes

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.

IntermediateCI/CD automationHands-on lab

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.

Portable CI helper architecture
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 pipefail

If 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 1

4. 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 EXIT

Test mktemp behavior on every supported OS, especially when custom templates are involved.

8. Avoid unnecessary GNU-only flags in cross-platform helpers

UtilityPortability issueSafer approach
dateFormatting and parsing flags differPrefer simple portable formats or runtime capability checks
sed -iGNU and BSD syntax differUse temp-file rewrite when practical
readlink/realpathAvailability/options differAvoid unless required or abstract it
xargsSome flags differCheck implementation before relying on extensions

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.sh

A 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/*.sh

Static 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.sh

Verification 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.
Next lesson

Tracing with set -x, PS4, and Selective Debug Output

Chapter 17 will focus on debugging, ShellCheck, shfmt, Bats, fixtures, golden files, and failure injection for production shell scripts.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.