Reliable Multiline String Outputs from GitHub Actions Steps

By Jacob Strieb.

Published on May 28, 2025.


GitHub Actions workflows are separated into steps. Each step may “output” strings that can be used in expressions elsewhere in the workflow. By default, it is not possible to have arbitrary multiline string outputs.1

We can achieve reliable multiline outputs by serializing output strings to JSON in the workflow step, and deserializing them in the expression that uses them. Jump to the end for an example of the solution, or continue on for an explanation.

An Actions workflow step sets an output by appending strings of the form name=value to the file whose path is stored in the environment variable GITHUB_OUTPUT. That file is newline-delimited. In practice, setting and using outputs looks like this:

on:
  push:

jobs:
  job-name:
    runs-on: ubuntu-latest
    steps:
      - id: make-output
        run: |
          echo "out_name=out_value" >> "${GITHUB_OUTPUT}"
          echo "other_output=value2" >> "${GITHUB_OUTPUT}"
      - run: |
          echo "${{ steps.make-output.outputs.out_name }}"
          echo "${{ steps.make-output.outputs.other_output }}"

Since the GITHUB_OUTPUT file is newline-delimited, multiline outputs like the following do not work correctly. In the best case, the workflow terminates early with an error. In the worst case, the truncated output is used, and there is a hard-to-debug issue far downstream from the source of the problem.2

on:
  push:

jobs:
  error-job:
    runs-on: ubuntu-latest
    steps:
      - id: make-output
        run: |
          # Causes an error
          echo "multiline=$(
            printf '%s\n%s\n%s\n' one two three
          )" >> "${GITHUB_OUTPUT}"
      - run: |
          echo "${{ steps.make-output.outputs.multiline }}"

Instead of trying to output a multiline string literal, we can instead output a single-line, serialized JSON string by piping to jq.

on:
  push:

jobs:
  job:
    runs-on: ubuntu-latest
    steps:
      - id: make-output
        run: |
          # Serialize using jq; no error
          echo "multiline=$(
            printf '%s\n%s\n%s\n' one two three \
              | jq --raw-input --compact-output --slurp
          )" >> "${GITHUB_OUTPUT}"
      - run: |
          # Deserialize using jq
          jq --raw-output <<"EOF"
          ${{ steps.make-output.outputs.multiline }}
          EOF

Despite being illustrative, the previous example is a bit silly. If we wanted to transfer multiline strings between steps, we would just write them to a file, since the filesystem is shared across steps within the same job.

On the other hand, multiline outputs are actually required when the output of a job step must be used as input for another job step in the Actions workflow YAML, via an expression. For example, the actions/cache workflow takes a path input that is a newline-delimited list of globbing patterns and paths to cache. If we want the list of paths to be set by a previous job step, then the step must be able to output multiline strings.

To deserialize the multiline string in an Actions workflow expression, we use the fromJSON function.

on:
  push:

jobs:
  job:
    runs-on: ubuntu-latest
    steps:
      - id: make-output
        run: |
          # In real life this would determine which paths to cache, rather than
          # being static
          echo "multiline=$(
            printf '%s\n' file1.txt file2.txt file3.txt \
              | jq --raw-input --compact-output --slurp
          )" >> "${GITHUB_OUTPUT}"
      - id: cache
        uses: actions/cache@v4
        with:
          key: key4
          path: |
            ${{ fromJSON(steps.make-output.outputs.multiline) }}
      - run: |
          if [ '${{ steps.cache.outputs.cache-hit }}' = 'true' ]; then
            cat file1.txt file2.txt file3.txt 
          else
            date | tee file1.txt
            sleep 1
            date | tee file2.txt
            sleep 1
            date | tee file3.txt
          fi

  1. Technically, it is possible to output multiline strings using (officially sanctioned) nasty hacks such as writing a heredoc to the GITHUB_OUTPUT file, or doing sketchy string replacement. Both of these will be buggy in rare cases, such as when user input contains either the heredoc delimiter or the string literals used for escaping.↩︎

  2. There will be an error and early termination unless the input being stored contains only name=value pairs separated by newlines.↩︎