The native architecture of GitHub Actions is designed around a directed acyclic graph (DAG) of jobs and steps, which fundamentally lacks a traditional "for loop" construct within its YAML syntax. While a developer can define a sequence of steps, there is no built-in for or while keyword at the workflow orchestration level to repeat a step multiple times based on a dynamic list. To achieve iterative execution, engineers must implement specific patterns that shift the looping logic from the YAML orchestrator down to the shell environment, the job strategy matrix, or specialized third-party actions. Understanding these patterns is critical for automating repetitive tasks such as testing across multiple operating systems, processing a list of files, or deploying to various environments.
The Matrix Strategy as an Orchestration Loop
Matrix builds represent the most powerful native method for simulating a "for loop" at the job level. Rather than repeating a single step within one job, a matrix allows the workflow to spawn multiple independent jobs concurrently based on a defined set of parameters.
The technical mechanism involves defining a strategy.matrix object in the job configuration. GitHub Actions interprets this matrix by calculating the Cartesian product of all provided arrays. If a developer defines a matrix with two operating systems and three versions of Node.js, the system automatically generates six distinct jobs. This mimics a nested for loop where the outer loop iterates over the OS and the inner loop iterates over the language version.
The real-world impact of this approach is a massive increase in testing coverage and a significant reduction in execution time. Because matrix jobs run in parallel on separate runners, the total time to complete the suite is limited by the longest single job rather than the sum of all iterations. This prevents the "bottleneck" effect seen in sequential shell loops.
From a contextual perspective, the matrix strategy transforms a static workflow into a dynamic one. It eliminates the need for duplicate workflow files, which would otherwise be required to maintain separate pipelines for different environment configurations.
The following table details the operational characteristics of the Matrix Strategy:
| Feature | Detail | Technical Impact |
|---|---|---|
| Execution Mode | Parallel | Drastically reduces total wall-clock time |
| Scope | Job-level | Each iteration gets a fresh virtual machine/container |
| Trigger | strategy.matrix |
Automated job spawning based on array length |
| Use Case | Multi-OS, Multi-Version testing | Ensures compatibility across diverse environments |
Dynamic Matrix Generation via Job Outputs
A more advanced iteration pattern involves the use of a dynamic matrix, where the list of items to loop over is not hardcoded but generated during the workflow execution. This is the only way to natively loop a step when the number of iterations depends on the state of the repository, such as a list of files.
This process requires a two-job architecture. The first job, often acting as a "setup" or "discovery" phase, identifies the items to be processed and outputs them as a JSON array. In a practical scenario, such as listing all PNG files in a directory, a shell command is used to gather the filenames and format them using jq.
The technical implementation follows this flow:
1. A setup job executes a command like ls *.png.
2. The output is piped through jq -R -s -c 'split("\n")[:-1]' to convert the newline-separated list into a valid JSON array.
3. This array is assigned to a job output using the ::set-output or similar mechanism.
4. A subsequent job defines its matrix using the fromJson() function, referencing the output of the first job.
The impact for the user is the ability to automate tasks based on actual content. For example, if a repository contains ten images, the workflow will spawn ten parallel jobs; if it contains one hundred, it will spawn one hundred. This ensures that the automation scales linearly with the project size.
Example implementation for dynamic file looping:
```yaml
name: listfiles
on:
workflow_dispatch:
jobs:
list-png-files:
runs-on: ubuntu-latest
outputs:
file: ${{ steps.set-files.outputs.file }}
steps:
- uses: actions/checkout@v2
- id: set-files
run: echo "::set-output name=file::$(ls *.png | jq -R -s -c 'split("\n")[:-1]')"
check:
needs: list-png-files
runs-on: ubuntu-latest
strategy:
matrix:
file: ${{ fromJson(needs.list-png-files.outputs.file) }}
steps:
- run: |
echo "${{ matrix.file }}"
```
Shell-Level Iteration via Environment Variables
When the overhead of spawning multiple jobs is unnecessary, the most efficient way to implement a loop is to push the logic down into a Bash script. This is a "micro-loop" where the iteration happens within a single step on a single runner.
The technical process involves passing a list of items as an environment variable to the shell. Because GitHub Actions environment variables are strings, the list is typically passed as a comma-separated or space-separated string. To process this in Bash, the developer must use string manipulation or substitution to convert the string into a format the for loop can iterate over.
For instance, a workflow can define an environment variable MY_LIST with the value "a","b","c". In the shell script, the syntax ${MY_LIST//,/ } is used to replace all commas with spaces, allowing the Bash for loop to treat each item as a separate element.
The impact of this method is high performance for small tasks. Unlike the matrix strategy, there is no delay caused by provisioning new virtual machines for each item. However, the trade-off is that the execution is sequential; if one item in the loop fails or hangs, it blocks all subsequent items.
Detailed bash implementation for list processing:
```bash
!/bin/bash
file: .github/scripts/print-list.sh
if [[ -z "${MYLIST:-}" ]]; then
echo "ERROR: Missing env var MYLIST"
exit 1
fi
for i in ${MYLIST//,/ }
do
echo "$i"
iwithout_quotes=$(sed -e 's/^"//' -e 's/"$//' <<<"$i")
echo "$i"
done
```
In this script, the sed command is used to strip leading and trailing quotes from the items, ensuring that the resulting string is clean and usable for further CLI commands.
Third-Party Implementation: The Command Loop Action
For users who prefer a declarative approach over writing custom Bash scripts or complex matrix logic, third-party actions like cliffano/command-loop-action provide a streamlined wrapper for shell looping.
This action abstracts the complexity of shell iteration by providing a specific interface for inputs. It allows the user to define a list of items and a command to execute against each item without needing to manually handle string splitting or environment variable passing.
The technical specifications for this action include:
- Input
items: A string containing the list of items. These can be comma or space-separated, or use a custom delimiter. - Input
command: The shell command to run. The current item in the loop is accessed via the$ITEMenvironment variable. - Input
delimiters: An optional string that defines the character used to separate items, separated by a pipe character|.
The real-world consequence is a significant reduction in YAML boilerplate. Instead of writing a separate script file and managing the shell environment, the user can simply declare the loop in their workflow file.
Example usage of the Command Loop Action:
yaml
- uses: cliffano/command-loop-action@main
with:
items: '1:2:3:4:5:6:7:8:9:10'
command: 'echo "Count $ITEM"'
delimiters: ':'
This approach is particularly useful for simple repetitive tasks, such as pinging a list of servers or running a specific command against a set of version tags, where the overhead of a full matrix is not justified.
Comparative Analysis of Looping Methodologies
Choosing the correct iteration pattern depends on the scale of the task, the requirement for parallelism, and the source of the data.
- Matrix Strategy: Best for heavy tasks (testing, building) that require isolated environments and maximum speed through parallelism. It is the "macro-loop."
- Dynamic Matrix: Best for tasks where the number of iterations is unknown until runtime (e.g., looping over files in a directory).
- Bash Loops: Best for lightweight tasks that must happen sequentially within a single job to maintain state or avoid runner startup overhead.
- Command Loop Action: Best for developers who want a clean, maintainable YAML configuration without external script dependencies.
The technical trade-offs are summarized in the following table:
| Method | Parallelism | Isolation | Setup Complexity | Execution Speed |
|---|---|---|---|---|
| Matrix | High | Full (New VM) | Medium | Very Fast |
| Dynamic Matrix | High | Full (New VM) | High | Very Fast |
| Bash Loop | None | None (Same VM) | Low | Fast (No overhead) |
| Loop Action | None | None (Same VM) | Very Low | Fast (No overhead) |
Conclusion: Strategic Implementation of Iterative Logic
The absence of a native for keyword in GitHub Actions is not a limitation, but rather a design choice that forces a distinction between orchestration and execution. By leveraging the Matrix Strategy, developers can achieve massive parallelism and comprehensive environment coverage, effectively transforming the workflow into a distributed loop. When the iteration is based on dynamic data, the combination of jq and fromJson() allows for a flexible, data-driven pipeline.
For smaller, sequential tasks, the transition of logic to the Bash shell or the use of specialized actions like cliffano/command-loop-action ensures that the workflow remains lean and avoids the latency associated with spawning multiple jobs. The decision process for an engineer should follow a hierarchy of needs: if isolation and speed are required, choose the Matrix; if simplicity and sequence are required, choose the Shell; if a third-party abstraction is preferred for readability, use a specialized action. Ultimately, the mastery of these patterns allows for the creation of highly scalable, efficient, and maintainable CI/CD pipelines that can handle any volume of repetitive tasks.