Simulating CI Matrix Builds Locally with Bash Loops
AI generated
$_
#!/
Bash · CI/CD · Test Automation · Docker
Simulating Matrix Builds with Bash Loops
Test version and environment combinations locally before writing the CI matrix

Before a matrix configuration goes live in GitHub Actions or GitLab CI, prototyping it in Bash pays off: run the same combinatorics of versions, operating systems and environments locally through a loop, debug failures directly in the shell, and only then write the final YAML matrix. That saves expensive trial-and-error commits and shows which combinations actually make sense.

16 min read for loops · xargs -P · Docker GitHub Actions · GitLab CI

1. Why simulate a matrix build idea locally in Bash first

A matrix build tests the same codebase against several combinations of language version, operating system and dependency set, for example PHP 8.2, 8.3 and 8.4 each against two database versions. In a CI configuration that quickly turns into a dozen parallel jobs, but every change to that matrix costs a commit, a push and several minutes waiting for all runners to finish, just to discover a typo in an image tag.

A local Bash loop that runs the same combinatorics delivers the same information in seconds instead of minutes, because it runs directly on your own machine without a queue, without runner assignment and without network overhead. Whoever builds the matrix as a Bash prototype first catches wrong version numbers, missing environment variables and unexpected dependency conflicts before they show up in the real CI pipeline, where they are considerably more expensive to debug.

2. Basic pattern: a Bash loop over versions and environments

The core of a local matrix simulation is a nested loop over two or more Bash arrays, one per matrix dimension. Every combination of the array elements corresponds exactly to one cell of the later CI matrix, and the loop walks through them in the same cartesian order that strategy.matrix in GitHub Actions would use later.

It matters to name the dimensions from the start exactly as they will later be called in the YAML matrix, for instance PHP_VERSIONS and DB_VERSIONS, so the translation at the end is a pure copy-paste of the values instead of a new naming exercise. That reduces the risk that the local simulation ends up testing something different from the eventual real matrix.


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

readonly PHP_VERSIONS=("8.2" "8.3" "8.4")
readonly DB_VERSIONS=("mysql:8.0" "mariadb:10.11")

for php in "${PHP_VERSIONS[@]}"; do
  for db in "${DB_VERSIONS[@]}"; do
    echo "=== Matrix cell: PHP ${php} x ${db} ==="
    # Placeholder for the actual test run of this combination
    echo "  -> would test PHP ${php} against ${db} here"
  done
done

3. Using Docker as an isolation layer per matrix cell

A plain Bash loop alone is not enough to realistically test different PHP versions, because the local machine usually only has one version installed. Docker closes that gap: for every matrix cell a container starts with exactly the image version the later CI matrix would use, so the simulation reflects not just the logic but also the actual runtime environment.

The important part is using the same image tags that will end up in the CI configuration, for example php:8.3-cli instead of a locally diverging version. With the project directory bind-mounted, tests inside the container run exactly against the current working state, without artifacts from one matrix cell leaking into another.


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

readonly PHP_VERSIONS=("8.2" "8.3" "8.4")

for php in "${PHP_VERSIONS[@]}"; do
  echo "=== PHP ${php} in Docker ==="
  docker run --rm \
    -v "$PWD":/app -w /app \
    "php:${php}-cli" \
    ./run-tests.sh
done

4. Running matrix cells in parallel instead of sequentially

A sequential loop over six or more matrix cells adds up the runtime of every single test run, while a real CI matrix distributes the cells across multiple runners in parallel. To at least partly mirror that advantage locally, xargs -P reads the combinations from a list and starts up to a configured number of them as separate processes at the same time.

The number of parallel processes should be based on the local machine's core count, not the number of CI runners, since a single machine has to process every cell itself. Whoever prefers the classic & pattern followed by wait collects process IDs in an array and waits for all of them together at the end, which makes tracking individual exit codes easier.


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

readonly PHP_VERSIONS=("8.2" "8.3" "8.4")

run_cell() {
  local php="$1"
  docker run --rm -v "$PWD":/app -w /app "php:${php}-cli" ./run-tests.sh
}
export -f run_cell

printf '%s\n' "${PHP_VERSIONS[@]}" | xargs -P 3 -I{} bash -c 'run_cell "$@"' _ {}

5. Collecting results and producing a report

Test runs started in parallel scribble their output over each other on the same terminal unless every matrix cell is redirected to its own log file. An associative array that stores the exit code of each cell under a descriptive key like php83-mariadb makes it clear at the end, in a single loop, which combinations failed, without opening every scattered log file by hand.

A simple closing report that summarizes pass and fail lines mirrors, in reduced form, the matrix overview GitHub Actions or GitLab CI shows in the web interface. For quick local debugging that is entirely sufficient, because the goal here is not a polished dashboard but seeing within seconds which version combination needs attention next.


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

declare -A RESULTS=()

for php in 8.2 8.3 8.4; do
  log="/tmp/matrix-${php}.log"
  if docker run --rm -v "$PWD":/app -w /app "php:${php}-cli" ./run-tests.sh > "$log" 2>&1; then
    RESULTS["php${php}"]="PASS"
  else
    RESULTS["php${php}"]="FAIL (see $log)"
  fi
done

echo "=== Matrix report ==="
for key in "${!RESULTS[@]}"; do
  echo "  ${key}: ${RESULTS[$key]}"
done

6. From local simulation to a real CI matrix configuration

Once the Bash simulation runs reliably, translating it into a real CI matrix is usually a formality: the values from PHP_VERSIONS and DB_VERSIONS move unchanged into strategy.matrix under GitHub Actions, or into the parallel: matrix: section under GitLab CI. Because every combination has already run locally, the usual series of small fix-up commits that only exist to find typos in version numbers disappears.

Whoever explicitly excludes certain combinations in the Bash loop, for instance because PHP 8.2 is not compatible with a certain database version, can carry those exclusions directly over as exclude entries in the YAML matrix. The local simulation then becomes not just a test run but also documentation of which cells of the matrix even make sense.

7. Advantages and disadvantages against a native CI matrix

The biggest advantage of the local Bash simulation is feedback speed: no waiting for free runners, no CI minute cost, and the ability to jump into a failed matrix cell with a debugger or interactive shell, which in a running CI pipeline is only possible with extra tooling like SSH debug sessions.

The downside is that a local machine never reaches the parallelism of a CI fleet with dozens of runners, and the nice matrix overview with green and red tiles, per-cell artifact uploads and automatic pull request comments is entirely missing. The Bash simulation is a development tool for the phase a matrix is being built in, not a replacement for production CI execution.

8. Caching and resource limits when testing locally

Every extra matrix dimension multiplies the number of Docker images needed, and without cleanup the local disk quickly fills up with dozens of image variants no longer needed after prototyping. Running docker image prune regularly after a completed simulation keeps disk usage in check without deleting images still actively in use.

Because a local machine typically has far fewer CPU cores and less memory than an entire CI runner fleet, the local matrix does not necessarily need to cover every single combination of the later real matrix. A representative subset, for instance the oldest and newest supported version per dimension, covers most real-world failure sources while keeping local runtime manageable.

9. When the Bash detour pays off, and when it does not

The Bash simulation detour pays off especially when building a new matrix, debugging a version-specific error that only shows up in one particular cell, or when CI minutes are expensive and every unnecessary pipeline run should be avoided. It is also useful when reproducing a reported bug that only occurs under an old language version, where the local loop is often faster than a CI retrigger.

For an already stable matrix that has not changed in months, the extra maintenance burden of a parallel Bash version does not pay off, since hardly any iteration happens anymore and the native CI matrix fully plays out its strengths in parallelism and reporting. The Bash simulation is a tool for the development phase, not for the steady-state operation of an established pipeline.

Criterion Bash loop locally Native CI matrix Recommendation
Feedback speed Seconds, no waiting for runners Minutes, depends on runner queue Bash for fast iteration
Parallelism Limited by local CPU cores Dozens of runners at once possible CI for large matrices
Cost No CI minutes consumed CI minutes per cell Bash during prototyping
Debugging Direct shell access to a failed cell Only through logs or SSH debug sessions Bash for root-cause analysis
Reporting Simple text report Graphical matrix overview, PR comments CI for production operation

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

Simulating Matrix Builds with Bash: The Essentials at a Glance

Core idea

Nested Bash loops over arrays mirror the same combinatorics that later ends up in strategy.matrix or parallel: matrix.

Isolation

Docker with the same image tags as the later CI matrix makes the local simulation realistic, not just logically correct.

Parallelism

xargs -P or the & with wait pattern shortens total runtime, bounded by the local CPU core count.

Limits

No runner parallelism, no graphical reporting. The Bash simulation is a development tool, not a CI replacement.

11. FAQ: Simulating Matrix Builds with Bash: The Essentials at a Glance

1What is a matrix build in CI in the first place?
A matrix build tests the same codebase against multiple combinations of versions, operating systems or dependencies, for example three PHP versions against two databases. Every combination runs as its own job.
2Why not just test in the real CI right away?
Every change to the CI matrix requires a commit, a push and several minutes waiting for runners. A local Bash loop delivers the same information in seconds and saves unnecessary trial-and-error commits.
3How do I structure Bash arrays for multiple dimensions?
One array per dimension, for instance PHP_VERSIONS and DB_VERSIONS, with nested for loops that walk through all combinations cartesian-style. Name them the same way they will appear in the YAML matrix later.
4How do I safely parallelize matrix cells?
With xargs -P and a function made available via export -f, or with the classic & and wait pattern that collects process IDs in an array and waits for all of them together at the end.
5How do I translate the result into GitHub Actions matrix syntax?
The values from the Bash arrays move unchanged into strategy.matrix. Locally excluded, incompatible combinations get carried over there as exclude entries.
6What is the difference to GitLab parallel:matrix?
Functionally identical, only the YAML syntax differs. GitLab uses parallel: matrix: with lists of variable objects instead of GitHub Actions' include/exclude structure.
7How do I avoid overly large local matrices?
Instead of testing every combination of the later CI matrix locally, a representative subset usually suffices, for instance the oldest and newest supported version per dimension.
8How do I reliably collect exit codes?
With an associative array that stores the exit code or status of each matrix cell under a descriptive key, and a final loop that prints a report.
9Can I replace Docker with something lighter?
For pure language versions, version managers like phpenv or asdf can be enough when full operating system isolation is not needed. For realistic CI reproduction, Docker stays closer to the actual runner setup.
10When should I skip the Bash simulation entirely?
For an already stable matrix without current iteration, the extra maintenance burden of a duplicate version no longer pays off. The native CI matrix then handles the task on its own.