Ken Muse

Dynamic Build Matrices in GitHub Actions


Today’s post is more of a short tip. Using a matrix strategy in a build makes it easy to repeat the same set of steps with different sets of parameters. For each set of parameters, a new run will be created. These are commonly used to create a build (or test) for multiple platforms, split tests into multiple runs, or execute code in multiple environments or containers.

Actions are far more configurable than most people realize, thanks to the power of expressions. Most of the fields in a workflow that can accept a value will also accept an expression. The expression can provide a single value (such as a string) or a complex object (using fromJson). Creating dynamic values often requires some amount of compute to run the script or programming logic. As a result, the first step is to create a job. Next, in the steps perform any logic or calculations in the language of your choice. You can then append a line to a special file to create an output. That file is stored in the environment variable GITHUB_OUTPUT. It simply expects to receive an entry in the form name=value. To be able to retrieve that value, make sure to provide an id for the step. You can then use outputs at the job level to declare a job-level output based on your step output.

Fun trick: on Linux, you can use the jq app to create properly escaped JSON object strings. Just pass in a named variable and value using --arg name value. You can then reference the value of the variable as $name and it will be expanded in the final result. If you use --argjson name value, you can provide JSON. For Windows, consider PowerShell and ConvertTo-Json.

Putting that all together, assume I have a bash script (createTargets.sh) that returns a JSON array of environment names. I want to create a variable called mymatrix that provides the array using the target field. I’ll create a step with the id of dataStep. The output JSON needs to look like this:

1{
2    "target": ["dev", "qa", "prod"]
3}

This gives me a basic structure for the job.

 1jobs:
 2  setup:
 3    runs-on: ubuntu-latest
 4    outputs:
 5      mymatrix: ${{ steps.dataStep.outputs.myoutput }}
 6    steps:
 7      - id: dataStep
 8        run: |
 9          TARGETS=$(./createTargets.sh)
10          echo "myoutput=$(jq -cn --argjson environments $TARGETS '{target: $environments}')" >> $GITHUB_OUTPUT          

I now have some JSON data stored in a job-level output called mymatrix. To consume that value in the next job (we’ll call it run-matrix), I need two things. First, I need to declare that run-matrix job needs the first job, setup. This makes its output variables available. It also ensures the setup step runs first. Next, I’m dynamically defining the entire matrix, and matrix is an object in the workflow file. As a result, I need to convert the JSON-encoded string in the output variable to an object. I can do this using fromJson.

The resulting job (which just outputs each target name):

1run-matrix:
2  needs: setup
3  runs-on: ubuntu-latest
4  strategy:
5    matrix: ${{ fromJson(needs.setup.outputs.mymatrix) }}
6  steps:
7    run: echo ${{matrix.target}}

Easy, right? You’re also not limited to just defining the matrix dynamically. Many of the fields can use expressions. For example, I can dynamically define my runs-on to execute the steps on multiple operating systems using a matrix:

1  test:
2    strategy:
3      matrix:
4        platform: [windows, ubuntu, macos]
5
6    runs-on: ${{ matrix.platform }}-latest

There is one major exception to this – uses. Both reusable workflows and Actions use in steps must be static string values. This is because the process of executing the Action requires the system to extract the external workflows and Actions. It then resolves those to a specific version of the code, which are downloaded to runners before the steps execute. As a result, there’s currently no way to dynamically define the uses. The best way to work around that? Rely on scripts for the execution step. Using a run step, a script can run any required logic and dynamically call other scripts.

May the Fourth be with you!