Scripted Single and Multi-Dimensional JSON Matrices in GitHub Actions

With the matrix strategy in GitHub Actions, you can automatically create multiple job runs based on the combinations of the variables defined in the matrix. GitHub Actions workflows expose this through the jobs.<job_id>.strategy.matrix property, which enables running different variations of a job.

A matrix can be single or multi-dimensional, depending on the number of variables it defines. The number of resulting jobs for a given matrix can be calculated as follows:

Number of jobs = n1 * n2 * … nk
Where k represents the number of matrix variables and each n represents the number of values for a given variable.

Let’s apply this formula to an example of each type of matrix:

Single-dimension matrix

jobs:
  example_matrix:
    strategy:
      matrix:
        version: [10, 12, 14]

In this example, the version variable has 3 values. Since there is only one matrix variable with 3 possible values, we get:
Number of jobs = n₁ = 3

Multi-dimension matrix

jobs:
  example_matrix:
    strategy:
      matrix:
        os: [ubuntu-22.04, ubuntu-20.04]
        version: [10, 12, 14]
        arch: [x86, x64]

In this example, we have three variables, each with a different number of values. Applying the formula, we get:
Number of jobs = n₁ × n₂ × n₃ = 2 × 3 × 2 = 12

Creating a JSON matrix with a bash script

The elements inside the matrix property can also be generated as JSON and then passed to the workflow. The same properties defined in the YAML syntax can also be included in the JSON. Using the same YAML from the last example, we get the following JSON object:

{
  "os": ["ubuntu-22.04", "ubuntu-20.04"],
  "version": [10, 12, 14],
  "arch": ["x86", "x64"]
}

Let’s start with the matrix in a YAML file and then transform it to JSON using a script:

jobs:
  use-matrix:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        name: [first, second, third]
        number: [1, 2]
        letter: [A, B]
    steps:
      - run: |
          echo "${{ matrix.name }}: ${{ matrix.number }}${{ matrix.letter }}"

According to the formula, this should generate n₁ × n₂ × n₃ = 3 × 2 × 2 = 12 jobs, which is correct:

Now, let’s generate the same matrix from the previous job and expose it as an output to use later:

jobs:
  build-matrix:
    runs-on: ubuntu-latest
    outputs:
      custom-matrix: ${{ steps.build-matrix.outputs.custom-matrix }}
    steps:
      - name: Build matrix
        id: build-matrix
        run: |
          names=(first second third)
          numbers=(1 2)
          letters=(A B)

          # Build JSON with jq
          json=$(jq -n \
            --argjson names "$(printf '%s\n' "${names[@]}"   | jq -R . | jq -s .)" \
            --argjson nums  "$(printf '%s\n' "${numbers[@]}" | jq -R . | jq -s 'map(tonumber)')" \
            --argjson lets  "$(printf '%s\n' "${letters[@]}" | jq -R . | jq -s .)" '
            {
              name:   $names,
              number: $nums,
              letter: $lets
            }')

          {
            echo 'custom-matrix<<EOF'
            echo "$json"
            echo EOF
          } >> $GITHUB_OUTPUT
          
  use-matrix:
    runs-on: ubuntu-latest
    needs: build-matrix
    strategy:
      matrix: ${{ fromJson(needs.build-matrix.outputs.custom-matrix) }}
    steps:
      - run: |
          echo "${{ matrix.name }}: ${{ matrix.number }}${{ matrix.letter }}"

A few things to notice about this script (build-matrix job):

  • We are using jq so we can define the elements of the JSON as variables (arrays, in this case).
  • The matrix is a multi-line JSON object, which can be tricky to work with. Be sure to keep the same output format when exporting the matrix in the build-matrix job.
  • The fromJson function in the matrix property of the use-matrix job is what makes this work; without it, the workflow will fail.

This workflow generates the same number of jobs (12) as the previous example:

We can also make include and exclude part of the matrix, just like we do with the YAML syntax. However, when this is the case, GitHub Actions will only create a job for each element in the include property. It will not create jobs based on all combinations of the variables. This means the original formula to calculate the number of jobs no longer applies. Instead, the number of jobs is equal to the elements in include. Let’s consider an example:

jobs:
  build-matrix:
    runs-on: ubuntu-latest
    outputs:
      custom-matrix: ${{ steps.build-matrix.outputs.custom-matrix }}
    steps:
      - name: Build matrix
        id: build-matrix
        run: |
          names=(first second third)
          numbers=(1 2)
          letters=(A B)

          # Build JSON with jq
          json=$(jq -n \
            --argjson names "$(printf '%s\n' "${names[@]}"   | jq -R . | jq -s .)" \
            --argjson nums  "$(printf '%s\n' "${numbers[@]}" | jq -R . | jq -s 'map(tonumber)')" \
            --argjson lets  "$(printf '%s\n' "${letters[@]}" | jq -R . | jq -s .)" '
            {
              include: [
                {
                  name:   $names,
                  number: $nums,
                  letter: $lets
                }
              ],
              exclude: []
            }')

          {
            echo 'custom-matrix<<EOF'
            echo "$json"
            echo EOF
          } >> $GITHUB_OUTPUT
          
  use-matrix:
    runs-on: ubuntu-latest
    needs: build-matrix
    strategy:
      matrix: ${{ fromJson(needs.build-matrix.outputs.custom-matrix) }}
    steps:
      - run: |
          echo "${{ matrix.name }}: ${{ matrix.number }}${{ matrix.letter }}"

This script only creates one job because there is only one element inside include:

It can also be noted that, instead of printing the actual values from the JSON keys, GitHub just shows Array: ArrayArray. To get the real values, we need to reference the elements inside the arrays directly. Since we currently have only one element, we will fix this by including all possible combinations of variables in the next code example:

jobs:
  build-matrix:
    runs-on: ubuntu-latest
    outputs:
      custom-matrix: ${{ steps.build-matrix.outputs.custom-matrix }}
    steps:
      - name: Build matrix
        id: build-matrix
        run: |
          names=(first second third)
          numbers=(1 2)
          letters=(A B)

          # Build JSON with jq
          json=$(jq -n \
            --argjson names "$(printf '%s\n' "${names[@]}"   | jq -R . | jq -s .)" \
            --argjson nums  "$(printf '%s\n' "${numbers[@]}" | jq -R . | jq -s 'map(tonumber)')" \
            --argjson lets  "$(printf '%s\n' "${letters[@]}" | jq -R . | jq -s .)" '
            {
              include: [
                {
                  name:   $names,
                  number: $nums,
                  letter: $lets
                }
              ],
              exclude: []
            }')

          {
            echo 'custom-matrix<<EOF'
            echo "$json"
            echo EOF
          } >> $GITHUB_OUTPUT
          
  use-matrix:
    runs-on: ubuntu-latest
    needs: build-matrix
    strategy:
      matrix: ${{ fromJson(needs.build-matrix.outputs.custom-matrix) }}
    steps:
      - run: |
          echo "${{ matrix.name[0] }}: ${{ matrix.number[0] }}${{ matrix.letter[0] }}"

And this actually prints something:

Now, if we want to achieve the same result we had before adding include, where GitHub Actions automatically created a job for each possible combination of variables, we must first include all those combinations ourselves in the JSON. This is very simple to do using jq:

jobs:
  build-matrix:
    runs-on: ubuntu-latest
    outputs:
      custom-matrix: ${{ steps.build-matrix.outputs.custom-matrix }}
    steps:
      - name: Build matrix
        id: build-matrix
        run: |
          names=(first second third)
          numbers=(1 2)
          letters=(A B)

          # Build JSON with jq
          json=$(jq -n \
            --argjson names "$(printf '%s\n' "${names[@]}"   | jq -R . | jq -s .)" \
            --argjson nums  "$(printf '%s\n' "${numbers[@]}" | jq -R . | jq -s 'map(tonumber)')" \
            --argjson lets  "$(printf '%s\n' "${letters[@]}" | jq -R . | jq -s .)" '
            {
              include: [
                $names[] as $name
                | $nums[] as $num
                | $lets[] as $let
                | {
                    name:   $name,
                    number: $num,
                    letter: $let
                  }
              ],
              exclude: []
          }')

          {
            echo 'custom-matrix<<EOF'
            echo "$json"
            echo EOF
          } >> $GITHUB_OUTPUT
          
  use-matrix:
    runs-on: ubuntu-latest
    needs: build-matrix
    strategy:
      matrix: ${{ fromJson(needs.build-matrix.outputs.custom-matrix) }}
    steps:
      - run: |
          echo "${{ matrix.name }}: ${{ matrix.number }}${{ matrix.letter }}"

And now if we run this, we get:

In this context, the exclude instruction in the JSON seems to be ignored. In my tests, the workflow failed every time I added an element to be excluded. The cause appears to be the lack of axes (because GitHub doesn’t calculate jobs from combinations of variables, but instead creates one job per element in include, as discussed before). In other words, if you don’t want a specific job combination but still want to use include, make sure you don’t add that job to the resulting JSON when building it in your script.

To use exclude, one option is to remove include, define all the keys at the top level of the matrix, and then use exclude to ignore one of the jobs calculated by GitHub. The code looks like this:

jobs:
  build-matrix:
    runs-on: ubuntu-latest
    outputs:
      custom-matrix: ${{ steps.build-matrix.outputs.custom-matrix }}
    steps:
      - name: Build matrix
        id: build-matrix
        run: |
          names=(first second third)
          numbers=(1 2)
          letters=(A B)

          json=$(jq -n \
            --argjson names "$(printf '%s\n' "${names[@]}"   | jq -R . | jq -s .)" \
            --argjson nums  "$(printf '%s\n' "${numbers[@]}" | jq -R . | jq -s 'map(tonumber)')" \
            --argjson lets  "$(printf '%s\n' "${letters[@]}" | jq -R . | jq -s .)" '
            {
              name:   $names,
              number: $nums,
              letter: $lets,
              exclude: [
                { name: "second", number: 2, letter: "B" }
              ]
            }')

          {
            echo 'custom-matrix<<EOF'
            echo "$json"
            echo EOF
          } >> $GITHUB_OUTPUT

  use-matrix:
    runs-on: ubuntu-latest
    needs: build-matrix
    strategy:
      matrix: ${{ fromJson(needs.build-matrix.outputs.custom-matrix) }}
    steps:
      - run: |
          echo "${{ matrix.name }}: ${{ matrix.number }}${{ matrix.letter }}"

And with this, we get:

Notice that the combination { name: "second", number: 2, letter: "B" } was excluded, as intended.

| Theme: UPortfolio