GitHub Actions JSON: fromJSON, toJSON, Dynamic Matrices & Outputs

Last updated:

GitHub Actions provides two JSON functions in workflow expressions: fromJSON(value) converts a JSON string to a value (use in strategy.matrix for dynamic matrices), and toJSON(value) serializes a value to a formatted JSON string (use to debug context objects). Setting a JSON output from a step: echo "matrix=$(cat matrix.json)" >> $GITHUB_OUTPUT, then reading it with fromJSON(needs.build.outputs.matrix) in a downstream job's strategy.matrix. GitHub Actions context objects (github, runner, env) are all JSON-serializable — ${{ toJSON(github) }} dumps the full context as a formatted JSON string for debugging. This guide covers fromJSON/toJSON expression functions, dynamic job matrices from JSON, step outputs as JSON strings, parsing curl JSON responses in shell steps, workflow_dispatch JSON inputs, and the actions/github-script action for complex JSON manipulation.

fromJSON() and toJSON(): Expression Functions

GitHub Actions expression functions fromJSON() and toJSON() bridge the gap between shell string values and typed workflow data. fromJSON(value) parses a JSON string and returns the corresponding value — object, array, boolean, number, or null — usable anywhere an expression value is accepted. toJSON(value) does the reverse: it takes any expression value (context object, string, boolean) and returns a pretty-printed JSON string. Both functions are available in all expression contexts: if: conditions, strategy.matrix, step env:, job outputs:, and run: inline expressions.

# fromJSON() — parse a JSON string to a typed value
# Use case 1: boolean from string
- name: Set flag
  id: flags
  run: echo "skip=true" >> $GITHUB_OUTPUT

- name: Conditional step
  if: ${{ fromJSON(steps.flags.outputs.skip) == true }}
  run: echo "Skipping..."

# Use case 2: number comparison
- name: Check score
  run: echo "score=95" >> $GITHUB_OUTPUT

- name: Pass threshold
  if: ${{ fromJSON(steps.check.outputs.score) >= 80 }}
  run: echo "Score passed"

# Use case 3: array from JSON string
- name: Build matrix list
  id: list
  run: |
    echo 'items=["a","b","c"]' >> $GITHUB_OUTPUT

- name: Use as array
  run: |
    # In expressions, fromJSON converts the string to an array
    echo "Length: ${{ join(fromJSON(steps.list.outputs.items), ', ') }}"

# toJSON() — serialize any value to a JSON string
# Use case 1: dump the full github context for debugging
- name: Debug github context
  run: echo '${{ toJSON(github) }}'

# Use case 2: dump event payload (the webhook body that triggered the run)
- name: Print event payload
  run: |
    cat <<'EOF'
    ${{ toJSON(github.event) }}
    EOF

# Use case 3: dump all step results
- name: Debug steps
  if: always()
  run: echo '${{ toJSON(steps) }}'

# Use case 4: dump needs (upstream job outputs and results)
- name: Debug upstream jobs
  run: echo '${{ toJSON(needs) }}'

# Use case 5: toJSON in an env variable
- name: Pass context to script
  env:
    GITHUB_CONTEXT: ${{ toJSON(github) }}
  run: |
    echo "$GITHUB_CONTEXT" | jq .repository

fromJSON() throws an expression evaluation error if the input string is not valid JSON — the workflow step fails immediately, which is desirable behavior (fail fast rather than silently treating malformed JSON as a string). When generating JSON for fromJSON() consumption, always validate with jq empty <<< "$JSON" in the generating step before writing to GITHUB_OUTPUT. toJSON() is safe on any value and never fails — it produces null for undefined values and handles circular references in context objects gracefully.

Dynamic Job Matrices with fromJSON()

Static matrices hardcode build targets in the workflow YAML — dynamic matrices compute targets at runtime from a prior job output, an API response, or a file in the repository. The pattern requires three components: a setup job that generates JSON and writes it to GITHUB_OUTPUT, a job-level outputs: declaration that exposes the step output, and a matrix job that uses fromJSON() to expand the JSON string into matrix dimensions. The matrix job must declare needs: [setup-job] to access the outputs.

jobs:
  # Step 1: generate the matrix JSON
  setup:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.gen.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4

      # Option A: generate from a file in the repo
      - name: Load matrix from file
        id: gen
        run: |
          # matrix.json: [{"node":"18","os":"ubuntu"},{"node":"20","os":"macos"}]
          echo "matrix=$(jq -c . .github/matrix.json)" >> $GITHUB_OUTPUT

      # Option B: generate dynamically from an API call
      - name: Generate from API
        id: gen-api
        run: |
          ENVS=$(curl -s https://api.example.com/active-environments             | jq -c '[.[] | {env: .name, region: .region}]')
          echo "matrix=$ENVS" >> $GITHUB_OUTPUT

      # Option C: build inline with jq
      - name: Build inline matrix
        id: gen-inline
        run: |
          MATRIX=$(jq -cn '
            [
              {"node": "18", "os": "ubuntu-latest"},
              {"node": "20", "os": "ubuntu-latest"},
              {"node": "22", "os": "ubuntu-latest"},
              {"node": "20", "os": "macos-latest"}
            ]
          ')
          echo "matrix=$MATRIX" >> $GITHUB_OUTPUT

  # Step 2: consume the matrix in parallel jobs
  build:
    needs: setup
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include: ${{ fromJSON(needs.setup.outputs.matrix) }}
      fail-fast: false
      max-parallel: 4
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

  # Advanced: combine fromJSON matrix with static dimensions
  test-cross:
    needs: setup
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: ${{ fromJSON(needs.setup.outputs.matrix) }}
        browser: [chrome, firefox]
        # Exclude combinations
        exclude:
          - browser: firefox
            service: {name: "legacy-service"}
    steps:
      - run: echo "Testing ${{ matrix.service.name }} on ${{ matrix.browser }}"

The JSON written to GITHUB_OUTPUT must be compact (single-line) — multi-line JSON breaks the KEY=VALUE format that Actions uses to parse the output file. Always use jq -c (compact output) when generating matrix JSON. If your JSON source is already a file, read it with jq -c . to strip whitespace. The include key in strategy.matrix is the most flexible entry point for fromJSON() because it accepts an array of objects, where each object adds or overrides matrix dimensions for a specific combination. For multi-environment deployments targeting Kubernetes JSON configs or Docker JSON config, dynamic matrices are the standard approach to fan out identical jobs across cluster targets.

Setting JSON Step Outputs with GITHUB_OUTPUT

Step outputs in GitHub Actions are string key-value pairs written to the GITHUB_OUTPUT environment file. Because outputs are strings, JSON values must be serialized before writing and deserialized after reading. The critical constraint: the value portion cannot contain unescaped newlines in the simple KEY=VALUE format — always use jq -c to produce compact JSON or the heredoc delimiter syntax for multi-line values.

jobs:
  producer:
    runs-on: ubuntu-latest
    outputs:
      # Expose step outputs at the job level for downstream jobs
      result: ${{ steps.process.outputs.result }}
      config: ${{ steps.process.outputs.config }}
    steps:
      - uses: actions/checkout@v4

      # Pattern 1: compact JSON object as output
      - name: Set JSON output (compact)
        id: process
        run: |
          RESULT=$(jq -cn '{
            "status": "success",
            "version": "1.2.3",
            "count": 42,
            "tags": ["release", "stable"]
          }')
          echo "result=$RESULT" >> $GITHUB_OUTPUT

      # Pattern 2: compact JSON from file
      - name: Set JSON from file
        id: from-file
        run: |
          echo "config=$(jq -c . config.json)" >> $GITHUB_OUTPUT

      # Pattern 3: multi-line JSON using heredoc delimiter syntax
      # Use when the JSON might contain = signs or other special characters
      - name: Set multi-line JSON output
        id: multiline
        run: |
          JSON=$(cat <<'ENDJSON'
          {
            "key": "value with = signs",
            "nested": {"a": 1}
          }
          ENDJSON
          )
          echo "data<<EOF" >> $GITHUB_OUTPUT
          echo "$(echo "$JSON" | jq -c .)" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      # Pattern 4: build JSON incrementally
      - name: Build output JSON
        id: build
        run: |
          VERSION=$(git describe --tags --abbrev=0)
          SHA=$(git rev-parse --short HEAD)
          TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
          JSON=$(jq -cn             --arg version "$VERSION"             --arg sha "$SHA"             --arg ts "$TIMESTAMP"             '{version: $version, sha: $sha, timestamp: $ts}')
          echo "meta=$JSON" >> $GITHUB_OUTPUT

  consumer:
    needs: producer
    runs-on: ubuntu-latest
    steps:
      # Read JSON output from same job (steps.id.outputs.key)
      # — only in same job, different steps

      # Read job output from upstream job (needs.job.outputs.key)
      - name: Use upstream JSON output
        env:
          RESULT_JSON: ${{ needs.producer.outputs.result }}
        run: |
          echo "Status: $(echo "$RESULT_JSON" | jq -r .status)"
          echo "Version: $(echo "$RESULT_JSON" | jq -r .version)"
          echo "Count: $(echo "$RESULT_JSON" | jq -r .count)"

      # Use fromJSON() in expression to access fields directly
      - name: Conditional on JSON field
        if: ${{ fromJSON(needs.producer.outputs.result).status == 'success' }}
        run: echo "Build was successful"

The old ::set-output name=key::value workflow command syntax was deprecated in September 2022 and disabled in June 2023 — always use the GITHUB_OUTPUT file approach. When a JSON value contains the literal string EOF (rare but possible), choose a different heredoc delimiter. The 1 MB size limit applies per output value — for deployment manifests, test result files, or other large JSON, write to a file and upload as an artifact using actions/upload-artifact, then download in subsequent jobs with actions/download-artifact.

Parsing curl JSON Responses in Shell Steps

GitHub Actions shell steps run in a standard Linux/macOS/Windows environment with curl and jq pre-installed on all runner types. The canonical pattern for consuming a JSON API in a workflow step: fetch with curl -s, pipe to jq, and write selected fields to GITHUB_OUTPUT for use in later steps or jobs. Always handle HTTP errors explicitly — curl exits 0 even on 4xx/5xx responses by default.

- name: Call JSON API and extract fields
  id: api
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
  run: |
    # -s: silent (no progress bar)
    # -f: fail on 4xx/5xx (exit code 22)
    # -H: set headers
    RESPONSE=$(curl -sf       -H "Authorization: Bearer $API_TOKEN"       -H "Accept: application/json"       https://api.example.com/releases/latest)

    # Exit if curl failed
    if [ $? -ne 0 ]; then
      echo "API call failed" >&2
      exit 1
    fi

    # Extract fields with jq
    VERSION=$(echo "$RESPONSE" | jq -r .tag_name)
    DOWNLOAD_URL=$(echo "$RESPONSE" | jq -r '.assets[0].browser_download_url')
    PRERELEASE=$(echo "$RESPONSE" | jq -r .prerelease)

    # Write to GITHUB_OUTPUT
    echo "version=$VERSION" >> $GITHUB_OUTPUT
    echo "download_url=$DOWNLOAD_URL" >> $GITHUB_OUTPUT
    echo "prerelease=$PRERELEASE" >> $GITHUB_OUTPUT

    # Write full response as compact JSON output
    echo "payload=$(echo "$RESPONSE" | jq -c .)" >> $GITHUB_OUTPUT

- name: Use GitHub REST API with GITHUB_TOKEN
  id: gh-api
  run: |
    # GitHub token is available automatically — no secrets needed
    RESPONSE=$(curl -sf       -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"       -H "Accept: application/vnd.github+json"       -H "X-GitHub-Api-Version: 2022-11-28"       "https://api.github.com/repos/${{ github.repository }}/pulls?state=open")

    PR_COUNT=$(echo "$RESPONSE" | jq 'length')
    echo "open_prs=$PR_COUNT" >> $GITHUB_OUTPUT

- name: POST JSON to an API
  run: |
    PAYLOAD=$(jq -cn       --arg ref "${{ github.sha }}"       --arg env "staging"       '{ref: $ref, environment: $env, auto_merge: false}')

    curl -sf       -X POST       -H "Content-Type: application/json"       -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}"       -d "$PAYLOAD"       https://api.example.com/deployments

- name: Handle paginated JSON responses
  run: |
    PAGE=1
    ALL_ITEMS="[]"
    while true; do
      RESPONSE=$(curl -sf         -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"         "https://api.github.com/repos/${{ github.repository }}/issues?per_page=100&page=$PAGE")
      COUNT=$(echo "$RESPONSE" | jq 'length')
      if [ "$COUNT" -eq 0 ]; then break; fi
      ALL_ITEMS=$(echo "$ALL_ITEMS $RESPONSE" | jq -sc 'add')
      PAGE=$((PAGE + 1))
    done
    echo "total=$(echo "$ALL_ITEMS" | jq 'length')" >> $GITHUB_OUTPUT

Use -sf (silent + fail) rather than -s alone so that curl exits non-zero on HTTP errors, causing the workflow step to fail visibly rather than silently producing an empty or error-body response. When the API response contains HTML error pages (common with misconfigured proxies or CDNs), jq will fail with a parse error — add -H "Accept: application/json" to request JSON specifically. For GitHub API calls, always include the X-GitHub-Api-Version: 2022-11-28 header to pin to a stable API version and avoid unexpected breaking changes. See also: AWS JSON APIs for patterns when calling AWS service endpoints from workflow steps.

workflow_dispatch JSON Inputs

workflow_dispatch triggers allow manual workflow runs with user-supplied inputs. GitHub Actions does not have a native JSON input type — all workflow_dispatch inputs are strings regardless of the type: declaration. To accept JSON, define a string input, document the expected JSON schema in the description, provide a valid JSON default, and parse with fromJSON() or jq inside the workflow. Input values from workflow_dispatch are available as ${ ${{ inputs.name }} } in expressions and ${ ${{ inputs.name }} } in env: blocks for shell access.

on:
  workflow_dispatch:
    inputs:
      # JSON string input — document the schema in description
      config:
        description: |
          JSON deployment config. Schema:
          {"env": "staging|production", "replicas": 1-10, "debug": true|false}
        required: true
        type: string
        default: '{"env":"staging","replicas":2,"debug":false}'

      # Simple JSON array input
      services:
        description: 'JSON array of service names, e.g. ["api","worker","scheduler"]'
        required: false
        type: string
        default: '["api","worker"]'

      # JSON object for feature flags
      features:
        description: 'Feature flags as JSON object'
        required: false
        type: string
        default: '{"new_ui":false,"beta_api":false}'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      # Validate JSON inputs before using them
      - name: Validate inputs
        run: |
          echo '${{ inputs.config }}' | jq empty || {
            echo "Invalid JSON in config input" >&2
            exit 1
          }
          echo '${{ inputs.services }}' | jq empty || {
            echo "Invalid JSON in services input" >&2
            exit 1
          }

      # Access fields with fromJSON() in expressions
      - name: Deploy to environment
        env:
          TARGET_ENV: ${{ fromJSON(inputs.config).env }}
          REPLICAS: ${{ fromJSON(inputs.config).replicas }}
        run: |
          echo "Deploying to $TARGET_ENV with $REPLICAS replicas"

      # Access fields in shell with jq
      - name: Parse config in shell
        run: |
          CONFIG='${{ inputs.config }}'
          ENV=$(echo "$CONFIG" | jq -r .env)
          REPLICAS=$(echo "$CONFIG" | jq -r .replicas)
          DEBUG=$(echo "$CONFIG" | jq -r .debug)
          echo "Environment: $ENV, Replicas: $REPLICAS, Debug: $DEBUG"

      # Use JSON array input as a matrix in a child workflow
      # (or iterate in a shell loop)
      - name: Process each service
        run: |
          SERVICES='${{ inputs.services }}'
          echo "$SERVICES" | jq -r '.[]' | while read -r SERVICE; do
            echo "Processing service: $SERVICE"
          done

      # Conditional logic based on JSON field
      - name: Enable debug mode
        if: ${{ fromJSON(inputs.config).debug == true }}
        run: echo "Debug mode is enabled"

      # Merge inputs with defaults using jq
      - name: Apply defaults to sparse input
        run: |
          DEFAULTS='{"env":"staging","replicas":1,"debug":false,"timeout":300}'
          INPUT='${{ inputs.config }}'
          MERGED=$(echo "$DEFAULTS $INPUT" | jq -sc 'add')
          echo "Final config: $MERGED"

When invoking a workflow_dispatch workflow via the GitHub API or gh workflow run, pass JSON inputs as strings: gh workflow run deploy.yml -f config='{"env":"production","replicas":5}'. Single-quote the JSON string in shell to prevent the shell from interpreting the braces. In GitHub's web UI, the input field shows a text box — users must type valid JSON manually, so provide a clear description and a valid default. For complex configuration, consider a YAML or JSON config file committed to the repository and a simpler string input (like an environment name) that selects which config to load, reducing the risk of malformed JSON from manual input.

actions/github-script for Complex JSON Operations

The actions/github-script action runs JavaScript directly in a workflow step with full access to the GitHub API via the authenticated github Octokit client and the core toolkit for setting outputs and logging. It is the right tool when JSON manipulation requires logic too complex for shell and jq — nested transformations, conditional API calls, or building structured outputs from multiple API responses.

- name: Get release data and build deployment matrix
  id: matrix
  uses: actions/github-script@v7
  with:
    script: |
      // Fetch releases from GitHub API (authenticated automatically)
      const { data: releases } = await github.rest.repos.listReleases({
        owner: context.repo.owner,
        repo: context.repo.repo,
        per_page: 5,
      });

      // Build a matrix array from release data
      const matrix = releases
        .filter(r => !r.prerelease && !r.draft)
        .map(r => ({
          version: r.tag_name,
          download_url: r.assets[0]?.browser_download_url ?? '',
          published_at: r.published_at,
        }));

      // Set the matrix as a JSON string output
      core.setOutput('matrix', JSON.stringify(matrix));

      // Log for debugging (visible in the step log)
      core.info(`Generated matrix with ${matrix.length} entries`);
      core.info(JSON.stringify(matrix, null, 2));

- name: Parse and transform JSON payload
  uses: actions/github-script@v7
  with:
    script: |
      // Access the event payload (already parsed as an object)
      const payload = context.payload;
      const prNumber = payload.pull_request?.number;

      if (!prNumber) {
        core.setFailed('No pull request number in event payload');
        return;
      }

      // Fetch PR reviews as JSON
      const { data: reviews } = await github.rest.pulls.listReviews({
        owner: context.repo.owner,
        repo: context.repo.repo,
        pull_number: prNumber,
      });

      // Aggregate review states
      const summary = reviews.reduce((acc, review) => {
        acc[review.state] = (acc[review.state] ?? 0) + 1;
        return acc;
      }, {});

      core.setOutput('review_summary', JSON.stringify(summary));
      core.setOutput('approved', String(summary.APPROVED >= 2));

- name: Set multiple JSON outputs
  uses: actions/github-script@v7
  with:
    script: |
      // Read a JSON file from the workspace
      const fs = require('fs');
      const config = JSON.parse(fs.readFileSync('deployment-config.json', 'utf8'));

      // Validate required fields
      const required = ['environment', 'region', 'replicas'];
      for (const field of required) {
        if (config[field] === undefined) {
          core.setFailed(`Missing required field: ${field}`);
          return;
        }
      }

      // Set individual outputs
      core.setOutput('environment', config.environment);
      core.setOutput('region', config.region);
      core.setOutput('replicas', String(config.replicas));

      // Set the entire config as a JSON string output
      core.setOutput('config', JSON.stringify(config));

      // Set output conditionally
      if (config.environment === 'production') {
        core.setOutput('requires_approval', 'true');
      }

actions/github-script uses the same Octokit client as the rest of the GitHub Actions infrastructure — the GITHUB_TOKEN is injected automatically, and all API calls are pre-authenticated. The context object mirrors the workflow expression github context, but as a parsed JavaScript object rather than a string. Use core.setOutput(key, value) to set step outputs — values must be strings, so serialize objects with JSON.stringify(). For very large JSON payloads (approaching 1 MB), write to a file with fs.writeFileSync() and upload as an artifact rather than setting as a step output. Following JSON best practices for naming, structure, and validation applies equally to JSON passing between GitHub Actions jobs and to JSON consumed by external APIs.

Debugging Workflows: Dumping Context JSON with toJSON()

GitHub Actions context objects — github, env, vars, job, steps, runner, secrets, strategy, matrix, needs, and inputs — contain all the runtime information available to a workflow. toJSON() is the fastest way to inspect these objects without setting up external logging. Add a dedicated debug step that always runs (if: always()) to capture context state even when prior steps fail.

jobs:
  debug-all-contexts:
    runs-on: ubuntu-latest
    steps:
      - name: Dump all contexts
        if: always()
        env:
          GITHUB_CONTEXT: ${{ toJSON(github) }}
          ENV_CONTEXT: ${{ toJSON(env) }}
          VARS_CONTEXT: ${{ toJSON(vars) }}
          JOB_CONTEXT: ${{ toJSON(job) }}
          RUNNER_CONTEXT: ${{ toJSON(runner) }}
          STEPS_CONTEXT: ${{ toJSON(steps) }}
          INPUTS_CONTEXT: ${{ toJSON(inputs) }}
          MATRIX_CONTEXT: ${{ toJSON(matrix) }}
        run: |
          echo "=== GITHUB CONTEXT ==="
          echo "$GITHUB_CONTEXT" | jq .
          echo "=== EVENT PAYLOAD ==="
          echo "$GITHUB_CONTEXT" | jq .event
          echo "=== RUNNER CONTEXT ==="
          echo "$RUNNER_CONTEXT" | jq .
          echo "=== JOB CONTEXT ==="
          echo "$JOB_CONTEXT" | jq .

      # Most useful: dump just the event payload
      - name: Print event payload
        run: |
          cat <<'EOF'
          ${{ toJSON(github.event) }}
          EOF

      # Pretty-print with jq for better readability
      - name: Debug github context (pretty)
        run: |
          echo '${{ toJSON(github) }}' | jq '{
            event_name: .event_name,
            ref: .ref,
            sha: .sha,
            actor: .actor,
            workflow: .workflow,
            run_id: .run_id
          }'

  # Dump needs context in a downstream job
  downstream:
    needs: [build, test]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Inspect upstream job results
        run: |
          echo '${{ toJSON(needs) }}' | jq '{
            build_result: .build.result,
            test_result: .test.result,
            build_outputs: .build.outputs,
            test_outputs: .test.outputs
          }'

      # Conditional logic based on toJSON + fromJSON round-trip
      - name: Check if all jobs succeeded
        run: |
          NEEDS='${{ toJSON(needs) }}'
          FAILED=$(echo "$NEEDS" | jq '[.[] | select(.result != "success")] | length')
          if [ "$FAILED" -gt 0 ]; then
            echo "Some upstream jobs failed:"
            echo "$NEEDS" | jq '.[] | select(.result != "success") | {result}'
            exit 1
          fi

The secrets context is intentionally excluded from toJSON(secrets) output — GitHub redacts secret values and replaces them with *** in logs and context dumps. toJSON(github.event) is the most valuable debugging tool when diagnosing webhook-triggered workflow behavior — it shows the exact payload sent by GitHub, including all branch, PR, commit, and pusher details. Enable the ACTIONS_STEP_DEBUG secret (set to true) to automatically enable verbose debug logging, which outputs additional toJSON-equivalent context information for every step without modifying the workflow file.

Key Terms

fromJSON()
A GitHub Actions expression function that parses a JSON string and returns the corresponding typed value — object, array, string, number, boolean, or null. Used anywhere an expression is valid: strategy.matrix to expand a JSON string into matrix dimensions, if: conditions to access fields of JSON outputs, and env: to pass structured data to shell steps. The input must be a valid JSON string; malformed input causes an expression evaluation error that fails the step immediately. Available in all GitHub Actions expression contexts since the introduction of the expression engine.
toJSON()
A GitHub Actions expression function that serializes any value to a pretty-printed JSON string. Accepts any expression value: context objects (github, runner, job, steps, needs, matrix, inputs), booleans, numbers, strings, arrays, and objects. Never fails — returns null for undefined values and handles all built-in context types. Primary use case: debugging workflows by dumping context objects to the step log. In step env: blocks, the serialized string is available as an environment variable that can be piped to jq for further processing.
GITHUB_OUTPUT
An environment variable that holds the path to a special file used for passing outputs between steps and jobs. Write outputs using echo "key=value" >> $GITHUB_OUTPUT. Values must be strings; JSON values must be compact (no newlines) in the simple format. Multi-line values use the heredoc delimiter syntax: echo "key<<EOF", the value, then echo "EOF". Step outputs are read within the same job with ${ ${{ steps.step-id.outputs.key }} } and in downstream jobs (via the job-level outputs: declaration) with ${ ${{ needs.job-id.outputs.key }} }. Maximum value size: 1 MB. Replaced the deprecated ::set-output workflow command in 2022.
strategy.matrix
A GitHub Actions job property that creates parallel job runs by expanding a set of dimensions into all combinations (or explicit include entries). Defined as a YAML map of dimension names to arrays: matrix: node: [18, 20, 22] creates three parallel jobs. The include key adds extra combinations or properties to existing ones; exclude removes specific combinations. With fromJSON(), matrix values can be computed at runtime from a prior job output: include: ${ ${{ fromJSON(needs.setup.outputs.matrix) }} }. fail-fast (default true) cancels remaining matrix jobs when any job fails; max-parallel limits concurrent jobs.
workflow_dispatch
A GitHub Actions trigger event that enables manual workflow runs from the GitHub web UI, GitHub CLI (gh workflow run), or the GitHub API. Defined under on: workflow_dispatch: with an optional inputs: block that specifies named parameters (string, boolean, choice, number, or environment type). All input values are received as strings in the workflow regardless of declared type — use fromJSON(inputs.name) to parse JSON string inputs and inputs.name == 'true' to evaluate boolean inputs. Supports up to 10 inputs per workflow. The event payload is available as github.event.inputs (legacy) or inputs context (preferred).
actions/github-script
A GitHub-maintained action that runs JavaScript in a workflow step with pre-authenticated access to the GitHub API via Octokit (github variable), workflow utilities via @actions/core (core variable), and the workflow run context (context variable). Used for JSON manipulation that is too complex for shell + jq: transforming API responses, building structured outputs from multiple API calls, conditional logic on parsed JSON, and reading/writing JSON files. Set step outputs with core.setOutput(key, JSON.stringify(value)). Log at different levels with core.info(), core.warning(), and core.error(). Available at actions/github-script@v7.

FAQ

How do I use fromJSON() in GitHub Actions?

fromJSON(value) parses a JSON string and returns a typed value usable in expressions. Common uses: ${{ fromJSON(needs.build.outputs.matrix) }} in strategy.matrix to expand a JSON array into parallel jobs; ${{ fromJSON(inputs.config).env }} to access a field from a workflow_dispatch JSON input; ${{ fromJSON(steps.check.outputs.result).status == 'success' }} in an if: condition. The input must be a valid JSON string — generate it with jq -c to ensure compact, valid output. If the input is malformed, the expression fails and the step errors immediately, which is desirable for catching bad data early.

How do I create a dynamic matrix in GitHub Actions?

Three-step pattern: (1) In a setup job, generate a compact JSON array and write it to GITHUB_OUTPUT: echo "matrix=$(jq -c . targets.json)" >> $GITHUB_OUTPUT. (2) Expose it at the job level: outputs: matrix: ${{ steps.gen.outputs.matrix }}. (3) In the matrix job, declare needs: [setup] and use strategy: matrix: include: ${{ fromJSON(needs.setup.outputs.matrix) }}. The JSON must be an array of objects where each object defines the matrix dimension values for one parallel job. Use jq -c (compact output) to produce single-line JSON — multi-line values break the GITHUB_OUTPUT format and cause parse errors.

How do I set a JSON output in a GitHub Actions step?

Write compact JSON to GITHUB_OUTPUT: echo "result=$(jq -c . data.json)" >> $GITHUB_OUTPUT. The -c flag on jq produces compact single-line output, which is required because the KEY=VALUE format in GITHUB_OUTPUT does not support unescaped newlines. For multi-line JSON (e.g., JSON containing literal newlines in string values), use the heredoc delimiter syntax: echo "result<<EOF" >> $GITHUB_OUTPUT, then the value, then echo "EOF" >> $GITHUB_OUTPUT. Expose the step output at the job level with outputs: result: ${{ steps.step-id.outputs.result }} for downstream jobs to consume via needs.job-id.outputs.result.

How do I parse a JSON API response in GitHub Actions?

Use curl -sf to fetch and fail on HTTP errors, then pipe to jq: RESPONSE=$(curl -sf -H "Authorization: Bearer $TOKEN" https://api.example.com/data), then VERSION=$(echo "$RESPONSE" | jq -r .version). The -r flag on jq outputs raw strings without JSON quotes. Write extracted values to GITHUB_OUTPUT: echo "version=$VERSION" >> $GITHUB_OUTPUT. For GitHub API calls, use the built-in GITHUB_TOKEN secret — no configuration needed: curl -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/releases/latest. For complex transformations, use actions/github-script instead of shell + jq.

How do I pass JSON as a workflow_dispatch input?

Define a string input in the workflow_dispatch inputs: block with a valid JSON default: config: type: string default: '{"env":"staging"}'. All workflow_dispatch inputs are strings — use fromJSON(inputs.config) in expressions to access fields, or echo '${{ inputs.config }}' | jq -r .env in shell steps. When running via CLI: gh workflow run deploy.yml -f config='{"env":"production"}' — single-quote the JSON string to prevent shell interpretation. Validate the input before use: echo '${{ inputs.config }}' | jq empty exits non-zero if the JSON is malformed, failing the step early.

How do I debug GitHub Actions with toJSON()?

Add a debug step with if: always() so it runs even when prior steps fail: - name: Debug run: echo '${ ${{ toJSON(github) }} }' | jq .. Key contexts to dump: toJSON(github.event) for the webhook payload, toJSON(steps) for all step outputs and conclusions in the current job, toJSON(needs) for upstream job results and outputs, toJSON(inputs) for workflow dispatch inputs, and toJSON(matrix) for the current matrix combination. Pass context via env: to avoid shell quoting issues: env: CONTEXT: ${{ toJSON(github) }} then echo "$CONTEXT" | jq .. Alternatively, enable ACTIONS_STEP_DEBUG=true as a repository secret for verbose debug logging without modifying the workflow.

What is the maximum size for a GitHub Actions output?

Step output values written to GITHUB_OUTPUT are limited to 1 MB each. If your JSON payload exceeds this limit, use artifacts instead: upload with actions/upload-artifact in the producing job and download with actions/download-artifact in consuming jobs. Artifact files have a much higher size limit (up to 10 GB per artifact) and a configurable retention period (default 90 days). For JSON data shared between jobs, write to a temporary file, upload as an artifact with a deterministic name (deployment-config-${{ github.run_id }}), and download in the next job. GitHub secrets are limited to 64 KB per secret — do not use secrets for large JSON configs.

How do I use jq in GitHub Actions?

jq is pre-installed on all GitHub-hosted runners (ubuntu-latest, macos-latest, windows-latest) — no installation step is needed. Essential flags: -r outputs raw strings (no JSON quotes, required when writing to shell variables); -c outputs compact JSON (required for GITHUB_OUTPUT); -e exits non-zero if output is false/null (useful for validation); -n processes with no input (for constructing JSON from scratch). Pass shell variables safely: jq --arg name "$NAME" '.name == $name' (string) or jq --argjson count "$COUNT" '.count > $count' (JSON value). Build a matrix array from scratch: jq -cn '["node18","node20","node22"]'. Filter arrays: echo "$JSON" | jq -c '[.[] | select(.active)]'.

Further reading and primary sources