JSON in GitHub Actions: Parsing, fromJSON, and Dynamic Matrices

Last updated:

GitHub Actions uses JSON extensively — for dynamic matrices, step outputs, context objects, and even secrets. The built-in fromJSON() and toJSON() expression functions are the bridge between YAML workflow files and structured data. This guide covers every pattern you'll encounter in real-world CI/CD pipelines.

fromJSON() and toJSON() Expressions

GitHub Actions expressions live in ${{ ... }} delimiters. All values in expressions are strings, booleans, numbers, or null — there are no native objects or arrays unless you explicitly deserialize them.

# toJSON — serialize an object to a string
- name: Dump github context
  run: echo '${{ toJSON(github) }}'

# fromJSON — parse a string into a usable object
- name: Read version from output
  run: echo "Tag is ${{ fromJSON(steps.meta.outputs.json).tags[0] }}"

# Practical: pass structured data between jobs
jobs:
  build:
    outputs:
      config: ${{ toJSON(steps.load.outputs) }}
  deploy:
    needs: build
    steps:
      - run: echo "${{ fromJSON(needs.build.outputs.config).region }}"

toJSON() is most useful for debugging — dump the entire github, runner, or job context to understand what data is available. fromJSON() is the workhorse for consuming structured data in expressions.

Dynamic Job Matrices

Static matrices in strategy.matrix are defined inline. Dynamic matrices compute the values at runtime — useful for testing against versions read from a config file, or deploying to environments defined in a JSON file checked into the repo.

# versions.json (checked into repo)
["18", "20", "22"]

# .github/workflows/test.yml
jobs:
  generate-matrix:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: set-matrix
        run: echo "matrix=$(cat versions.json | jq -c .)" >> $GITHUB_OUTPUT

  test:
    needs: generate-matrix
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }}
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm test

The output must be a compact, single-line JSON array. jq -c . compacts any JSON input. The consumer job must declare needs: [generate-matrix] and reference the output with the exact variable name.

Dynamic Matrix with Objects

# environments.json
[
  {"env": "staging", "url": "https://staging.example.com", "region": "us-east-1"},
  {"env": "production", "url": "https://example.com", "region": "us-west-2"}
]

# Workflow
strategy:
  matrix:
    include: ${{ fromJSON(needs.gen.outputs.matrix) }}
steps:
  - name: Deploy
    run: |
      echo "Deploying to ${{ matrix.env }} at ${{ matrix.url }}"
      aws --region ${{ matrix.region }} s3 sync ./dist s3://bucket-${{ matrix.env }}

Parsing Step Outputs

The GITHUB_OUTPUT file stores key-value pairs where values are strings. Writing JSON to it and reading it back with fromJSON() is the standard pattern for passing structured data between steps.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Write JSON to output (compact = single line, safe for GITHUB_OUTPUT)
      - id: meta
        run: |
          DATA=$(jq -c '{
            version: .version,
            name: .name,
            timestamp: now | todate
          }' package.json)
          echo "data=$DATA" >> $GITHUB_OUTPUT

      # Read specific fields
      - name: Tag image
        run: |
          echo "Building version: ${{ fromJSON(steps.meta.outputs.data).version }}"
          docker build -t myapp:${{ fromJSON(steps.meta.outputs.data).version }} .

Multi-line JSON with Delimiter

# For pretty-printed JSON that contains newlines
- id: report
  run: |
    DELIMITER=$(openssl rand -hex 8)
    echo "json<<$DELIMITER" >> $GITHUB_OUTPUT
    jq '.' result.json >> $GITHUB_OUTPUT
    echo "$DELIMITER" >> $GITHUB_OUTPUT

# Read it back
- run: echo '${{ steps.report.outputs.json }}'

Use a random delimiter to avoid collisions with content. However, the fromJSON() expression function requires a string — if you use the heredoc approach, the multi-line JSON string is still valid input for fromJSON().

Using jq in Workflows

jq is pre-installed on ubuntu-latest, macos-latest, and windows-latest runners. It's the most versatile tool for JSON manipulation in shell steps.

# Extract scalar field
VERSION=$(jq -r '.version' package.json)

# Filter array
ACTIVE=$(jq '[.[] | select(.active == true)]' services.json)

# Compact for GITHUB_OUTPUT
echo "matrix=$(jq -c '.services' config.json)" >> $GITHUB_OUTPUT

# Inject shell variable into jq
ENV_CONFIG=$(jq --arg env "$DEPLOY_ENV" '.[$env]' environments.json)

# Transform and combine
PAYLOAD=$(jq -n   --arg version "$VERSION"   --arg sha "${{ github.sha }}"   '{version: $version, commit: $sha, timestamp: now | todate}')

# Conditional exit code (useful with if: steps.check.outcome == 'success')
jq -e '.status == "ok"' result.json

The -r flag removes surrounding quotes from string output. The -e flag makes jq exit with code 1 if the output is false or null — use it with if: steps.X.outcome == 'success' conditions.

Reading package.json in Workflows

Pulling version or other metadata from package.json is one of the most common JSON operations in Node.js CI pipelines.

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - id: pkg
        run: |
          echo "version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
          echo "name=$(jq -r '.name' package.json)" >> $GITHUB_OUTPUT
          echo "node=$(jq -r '.engines.node // ">=18"' package.json)" >> $GITHUB_OUTPUT

      - name: Create Release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: v${{ steps.pkg.outputs.version }}
          release_name: ${{ steps.pkg.outputs.name }} v${{ steps.pkg.outputs.version }}

      - name: Build Docker image
        run: |
          docker build             --label "version=${{ steps.pkg.outputs.version }}"             -t ghcr.io/${{ github.repository }}:${{ steps.pkg.outputs.version }} .

JSON Secrets and Config

GitHub Secrets are strings. You can store a JSON object as a single secret — useful for grouping related configuration values.

# Secret value stored in repository settings:
# DB_CONFIG = {"host":"db.prod.example.com","port":5432,"database":"myapp","ssl":true}

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Connect to database
        env:
          DB_HOST: ${{ fromJSON(secrets.DB_CONFIG).host }}
          DB_PORT: ${{ fromJSON(secrets.DB_CONFIG).port }}
          DB_NAME: ${{ fromJSON(secrets.DB_CONFIG).database }}
        run: ./scripts/migrate.sh

      # AWS credentials as JSON
      # AWS_CREDS = {"access_key_id":"AKIA...","secret_access_key":"...","region":"us-east-1"}
      - name: Configure AWS
        run: |
          aws configure set aws_access_key_id ${{ fromJSON(secrets.AWS_CREDS).access_key_id }}
          aws configure set aws_secret_access_key ${{ fromJSON(secrets.AWS_CREDS).secret_access_key }}
          aws configure set region ${{ fromJSON(secrets.AWS_CREDS).region }}

Note that jq-extracted values from secrets won't be automatically redacted in logs. Always assign secret fields to env: variables (which GitHub masks) rather than interpolating directly into run: commands.

Common Patterns Reference

TaskPattern
Read package.json versionjq -r '.version' package.json
Write JSON outputecho "key=$(jq -c . file.json)" >> $GITHUB_OUTPUT
Read JSON output field${{ fromJSON(steps.ID.outputs.key).field }}
Dynamic matrix${{ fromJSON(needs.gen.outputs.matrix) }}
JSON secret field${{ fromJSON(secrets.CONFIG).field }}
Dump context for debugecho '${{ toJSON(github) }}'
Inject variable into jqjq --arg k "$VAR" '.[$k]' file.json
Conditional on JSON valuejq -e '.status == "ok"' result.json

FAQ

What does fromJSON() do in GitHub Actions?

fromJSON() is a built-in GitHub Actions expression function that parses a JSON string into a native object or array. It is most commonly used in two places: converting a string output from a previous step into an object you can access with dot notation (e.g., fromJSON(steps.meta.outputs.json).tags), and turning a JSON array string into an actual array for matrix.include or strategy.matrix fields. Without fromJSON(), everything in GitHub Actions expressions is a string — the function bridges that gap. It is the counterpart of toJSON(), which serializes an object back to a string for storage or passing between steps.

How do I create a dynamic matrix from a JSON array in GitHub Actions?

A dynamic matrix requires two jobs: a generator job that outputs a JSON array, and a consumer job that reads it with fromJSON(). In the generator step, run a shell command that produces a JSON array and assign it to $GITHUB_OUTPUT: echo "matrix=$(jq -c . versions.json)" >> $GITHUB_OUTPUT. In the consumer job, set needs: [generator] and then use strategy: matrix: include: ${{ fromJSON(needs.generator.outputs.matrix) }}. The critical details: the array must be valid compact JSON (jq -c ensures this), the output name must match exactly, and the consumer job must list the generator in needs. This pattern fans out builds across versions or environments read from a file checked into the repository.

How do I parse JSON from a step output in GitHub Actions?

Step outputs are always strings. To write JSON, use the GITHUB_OUTPUT mechanism: echo "data=$(jq -c . result.json)" >> $GITHUB_OUTPUT. To read a field, use ${{ fromJSON(steps.STEP_ID.outputs.data).fieldName }}. The fromJSON() call converts the string back to an object so you can use dot notation. For nested fields, chain the dots: .meta.version. For arrays, use bracket notation: .tags[0]. One gotcha: always use jq -c (compact) when writing to GITHUB_OUTPUT — pretty-printed JSON with newlines breaks the single-line key=value format. If you need to pass large JSON objects, consider base64-encoding them to avoid any special character issues.

How do I use jq in GitHub Actions to process JSON?

jq is pre-installed on all GitHub-hosted runners. Use it in run: steps with standard shell syntax. Key patterns: extract a scalar (jq -r '.version' package.json), filter an array (jq '[.[] | select(.active)]'), compact output for GITHUB_OUTPUT (jq -c .), inject shell variables (jq --arg env "$ENV" '.[$env]'), conditional exit code (jq -e '.ok' exits 1 if falsy). The -r flag removes quotes from string output — essential when assigning to shell variables or using in Docker tags. The -e flag is useful for workflow conditionals: pair it with if: steps.check.outcome == 'success'.

How do I store and read JSON secrets in GitHub Actions?

GitHub Secrets are plain strings, so store a JSON object as a single secret by pasting the serialized value (e.g., {"host":"db.example.com","port":5432}) into the secret field in repository settings. In workflows, read individual fields with ${{ fromJSON(secrets.MY_SECRET).host }}. This groups related configuration values into one secret instead of many. The tradeoff: updating any single field requires re-saving the entire JSON string. Security note: fields accessed via fromJSON(secrets.X) in expressions are masked in logs, but if you pipe them through jq or shell commands, the extracted values may appear in logs. Always assign to env: to get automatic masking.

How do I write multi-line JSON to GITHUB_OUTPUT?

The simplest approach is to avoid multi-line JSON entirely by using jq -c . to compact it before writing. Compacted JSON has no literal newlines, so the simple key=value format works. If you must preserve pretty-printed JSON, use the delimiter syntax: pick a unique delimiter string (e.g., a UUID or random hex), then write KEY<<DELIM, followed by the JSON, followed by the delimiter alone on a line. Use openssl rand -hex 8 to generate a random delimiter each run — this prevents content from accidentally matching the delimiter and truncating the output.

How do I read package.json fields in a GitHub Actions workflow?

After actions/checkout@v4, use jq -r '.version' package.json in a run: step and write the result to $GITHUB_OUTPUT. Reference it in later steps with ${{ steps.STEP_ID.outputs.version }}. Common uses: Docker image tags, GitHub release names, npm publish versions, and engines.node for matrix testing. The -r flag is important — without it, jq wraps strings in quotes, giving you "1.2.3" instead of 1.2.3. You can read multiple fields in one step by writing multiple echo "key=..." >> $GITHUB_OUTPUT lines.

What is the difference between toJSON() and fromJSON() in GitHub Actions?

toJSON() serializes a GitHub Actions context object or expression value into a JSON string — primarily useful for debugging (dump the entire github or jobs context) and for passing structured data between jobs via string-only outputs. fromJSON() does the reverse: parses a JSON string back into an expression-accessible object with dot notation and array indexing. A typical round-trip: job A writes outputs.config: ${{ toJSON(matrix) }}; job B reads fromJSON(needs.jobA.outputs.config).node. The functions are inverses of each other, and together they solve the fundamental problem that GitHub Actions job outputs are strings while workflow logic often needs structured data.

Further reading and primary sources