JSON in CI/CD: GitHub Actions, GitLab CI, and Config Injection
Last updated:
CI/CD pipelines use JSON for dynamic matrix generation, artifact metadata, API responses from deployment services, and secrets stored as JSON strings. GitHub Actions' fromJSON() and toJSON() functions parse and serialize JSON in expressions. GitLab CI uses jq for JSON manipulation in shell scripts. Both platforms limit environment variable values — GitHub Actions to 32 KB and GitLab CI to 512 KB — so large JSON configs must be stored as files or secrets.
This guide covers dynamic matrix generation, JSON secrets injection, parsing deployment API responses, JSON Schema validation in CI, and GitLab CI JSON jobs configuration. For foundational GitHub Actions JSON patterns, see the companion guide on JSON in GitHub Actions.
Dynamic Matrix Generation with fromJSON()
Static matrices hard-code values in strategy.matrix. Dynamic matrices compute those values at runtime — from a JSON file, an API call, or a previous job's output. This pattern is the standard way to fan out parallel jobs across a variable number of versions or environments.
The pattern requires two jobs. The generator job reads or computes a JSON array and writes it to $GITHUB_OUTPUT. The consumer job declares needs: [generator] and uses fromJSON() to deserialize the array into the strategy.matrix field.
# versions.json (checked into repo)
["20", "22", "24"]
# .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=$(jq -c . versions.json)" >> $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 ci && npm testjq -c . compacts the JSON to a single line, which is required for the key=value format used by $GITHUB_OUTPUT. For matrices with multiple dimensions, use an array of objects and reference matrix.include with fromJSON(). You can also generate the matrix dynamically from an API: curl -s https://api.example.com/active-envs | jq -c '[.[].name]'.
API-driven parallel jobs are a powerful pattern for deployment pipelines: fetch the list of active environments from your platform API, generate a matrix, and deploy to each environment in parallel — all without updating the workflow YAML.
JSON Secrets: Storing and Injecting Structured Config
GitHub Secrets are plain strings, but you can store a complete JSON object as a single secret. This is useful when a group of related values — database credentials, cloud provider config, API keys with endpoints — belong together conceptually.
# Secret stored in repository settings (value is the JSON string):
# DB_CONFIG = {"host":"db.prod.example.com","port":5432,"database":"myapp","ssl":true}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Run migrations
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.shFor configs that exceed the 32 KB environment variable limit — or that contain characters problematic for YAML — use base64 encoding. Encode locally with base64 -w0 config.json (Linux) or base64 -i config.json (macOS), store the result as a secret, then decode it in the workflow:
- name: Restore config from secret
run: |
echo "${{ secrets.CONFIG_B64 }}" | base64 -d > config.json
# Verify it decoded correctly
jq '.version' config.jsonSecurity note: fromJSON(secrets.X).field values used in expressions are automatically masked in GitHub Actions logs. However, if you pass a secret field through jq or print it with echo, the extracted value will not be automatically masked. Always assign sensitive fields to env: variables to get automatic masking for shell command output.
Artifact Metadata JSON
Build artifacts often include a JSON manifest file that records metadata: version, commit SHA, build timestamp, included files, and checksums. Downstream jobs — deploy, publish, notify — read this manifest rather than passing individual values through GITHUB_OUTPUT.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Write artifact manifest
run: |
jq -n --arg version "$(jq -r '.version' package.json)" --arg sha "${{ github.sha }}" --arg ref "${{ github.ref_name }}" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{version: $version, commit: $sha, ref: $ref, buildAt: $ts}' > dist/manifest.json
- uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
- name: Read manifest and deploy
run: |
VERSION=$(jq -r '.version' dist/manifest.json)
echo "Deploying version $VERSION"
./scripts/deploy.sh "$VERSION"Storing metadata in the artifact itself — rather than passing it through job outputs — keeps the build job's output section clean and makes the artifact self-describing. Any job that downloads the artifact can read the manifest without depending on the build job's outputs.
For monorepos with multiple packages, generate a manifest per package and combine them: jq -s '{packages: .}' dist/*/manifest.json > dist/combined-manifest.json. Use jq's -s (slurp) flag to read all input files into an array.
Parsing Deployment API Responses
Deployment pipelines frequently call external APIs — cloud platforms, PaaS providers, release tracking systems — and need to parse the JSON response to check status, extract a deployment ID, or poll until completion. curl combined with jq handles all of these patterns.
- name: Trigger deployment
id: deploy
run: |
RESPONSE=$(curl -s -X POST https://api.deploy.example.com/deploys -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" -H "Content-Type: application/json" -d '{"ref": "${{ github.sha }}", "env": "production"}')
# Extract deployment ID
DEPLOY_ID=$(echo "$RESPONSE" | jq -r '.id')
echo "deploy_id=$DEPLOY_ID" >> $GITHUB_OUTPUT
# Fail if API returned an error
echo "$RESPONSE" | jq -e '.error == null' > /dev/null
- name: Poll deployment status
run: |
for i in $(seq 1 20); do
STATUS=$(curl -s https://api.deploy.example.com/deploys/${{ steps.deploy.outputs.deploy_id }} -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" | jq -r '.status')
echo "Status: $STATUS (attempt $i)"
[ "$STATUS" = "success" ] && exit 0
[ "$STATUS" = "failed" ] && exit 1
sleep 15
done
echo "Deployment timed out" && exit 1Use jq -e (exit-status mode) to fail a step when a JSON condition is not met — for example, jq -e '.status == "ok"' exits with code 1 if the condition is false, which automatically fails the GitHub Actions step. Combine with if: steps.check.outcome == 'success' for conditional downstream steps.
For GitHub API responses specifically, use the pre-authenticated gh CLI, which returns JSON and integrates directly with jq: gh api /repos/{owner}/{repo}/deployments | jq '.[0].id'. The gh CLI is also pre-installed on all GitHub-hosted runners.
JSON Schema Validation in CI
Validating JSON config files against a JSON Schema before deployment catches structural errors early — before they cause runtime failures in production. ajv-cli is the standard tool for this in Node.js-based pipelines; it exits with a non-zero code on validation failure, which automatically fails the CI step.
# package.json (in repo root or a tools package)
{
"devDependencies": {
"ajv-cli": "^5.0.0"
}
}
# .github/workflows/validate.yml
jobs:
validate-config:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
# Validate a single file
- run: npx ajv-cli validate -s schemas/app-config.schema.json -d config/app.json
# Validate all environment configs
- run: npx ajv-cli validate -s schemas/app-config.schema.json -d "config/envs/*.json"
# Validate with strict mode (no additional properties)
- run: npx ajv-cli validate -s schemas/api.schema.json -d api/openapi.json --strictPlace the validation step before any infrastructure provisioning steps — Terraform plan, Kubernetes apply, cloud deploy — so a malformed config file fails the pipeline in seconds rather than after minutes of infrastructure work.
For multi-schema projects, loop over schema-file pairs in a shell script. Use jq to read a validation manifest (a JSON file that maps schemas to config files) and generate the ajv-cli invocations dynamically. See the guide on JSON Schema testing in CI for advanced patterns including $ref resolution and custom error messages.
GitLab CI JSON: Artifacts, Reports, and Child Pipelines
GitLab CI has first-class support for JSON in several places: artifact reports (test results, coverage, SAST), dynamic child pipelines generated from JSON configs, and variable injection via artifacts:reports:dotenv. The .gitlab-ci.yml format is YAML, but JSON drives the dynamic behavior.
# .gitlab-ci.yml
# Generate child pipeline YAML from a JSON config
generate-pipeline:
stage: .pre
script:
- |
jq -r '
.environments[] |
"deploy-\(.name):
stage: deploy
script:
- ./deploy.sh \(.name) \(.region)
"
' pipeline-config.json > generated-pipeline.yml
artifacts:
paths:
- generated-pipeline.yml
trigger-pipelines:
stage: deploy
trigger:
include:
- artifact: generated-pipeline.yml
job: generate-pipeline
# JUnit test report (JSON converted to XML, or use native JUnit format)
test:
script:
- npm test -- --reporter json > test-results.json
- npx jest-junit # converts to JUnit XML
artifacts:
reports:
junit: junit.xml
when: always
# Coverage report (JSON format)
coverage:
script:
- npm run coverage -- --reporter json-summary
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xmlGitLab CI's artifacts:reports key accepts several JSON-adjacent formats: junit (XML but often generated from JSON), sast, dependency_scanning, and coverage_report. These integrate directly into GitLab's merge request UI, showing test failures and coverage diffs inline.
For environment-specific config in GitLab CI, use CI/CD variables with JSON values. GitLab supports up to 512 KB per variable — large enough for most configs. Use jq in script: blocks to parse these variables: echo "$APP_CONFIG" | jq -r '.database.host'. The artifacts:reports:dotenv format allows passing extracted JSON fields as variables to downstream jobs.
Environment-Specific JSON Config Injection
The overlay pattern keeps a single base config and per-environment override files. In CI, merge them at deploy time using jq's deep merge operator. This avoids duplicating shared configuration and makes environment differences explicit and reviewable.
# config/base.json
{
"logLevel": "info",
"timeout": 30,
"database": { "port": 5432, "ssl": true },
"features": { "analytics": false, "betaUI": false }
}
# config/production.json (only overrides)
{
"database": { "host": "db.prod.example.com", "pool": 20 },
"features": { "analytics": true }
}
# In CI workflow:
- name: Merge environment config
run: |
ENV=${{ matrix.environment }}
jq -s '.[0] * .[1]' config/base.json config/$ENV.json > config/merged.json
echo "Merged config for $ENV:"
jq '.' config/merged.json
- name: Deploy with merged config
run: |
./deploy.sh --config config/merged.json --env ${{ matrix.environment }}The jq * operator performs a recursive merge: scalars and arrays from the right object override those from the left, while nested objects are merged recursively. For arrays that should replace entirely (rather than merge), use jq -s '.[0] * .[1] | .features.allowedOrigins = .[1].features.allowedOrigins' to force replacement of specific keys.
Combine with a matrix strategy to deploy all environments in parallel:
jobs:
deploy:
strategy:
matrix:
environment: [staging, production]
environment: ${{ matrix.environment }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build merged config
run: jq -s '.[0] * .[1]' config/base.json config/${{ matrix.environment }}.json > config/merged.json
- name: Deploy
run: ./deploy.sh --config config/merged.jsonFor secret injection into the merged config, use jq to splice secret values from the environment: jq --arg db_pass "$DB_PASSWORD" '.database.password = $db_pass' config/merged.json > config/final.json. Never commit secret values to the repository — only the structural shape of the config belongs in version control. See the guide on JSON and environment variables for more patterns.
Definitions
fromJSON()- A built-in GitHub Actions expression function that parses a JSON string into a native object or array. Required because GitHub Actions expressions treat all values as strings by default. Used primarily to deserialize step outputs and secrets into structured objects for dot-notation access.
- Dynamic matrix
- A
strategy.matrixconfiguration in GitHub Actions where the matrix values are computed at runtime (by a generator job) rather than hard-coded in the YAML. Enables fan-out parallelism over a variable number of targets — node versions, environments, services — read from a file or API. - JSON secret
- A CI/CD secret whose value is a serialized JSON object rather than a scalar string. Allows grouping related configuration values (host, port, password, region) into a single secret. Accessed in GitHub Actions with
fromJSON(secrets.NAME).field. - ajv-cli
- A command-line interface for the Ajv JSON Schema validator. Validates one or more JSON files against a JSON Schema and exits with a non-zero code on failure, making it suitable for CI steps. Supports
$refresolution, strict mode, and glob patterns for batch validation. - Artifact metadata
- A JSON manifest file bundled with a CI build artifact that records provenance information: version, commit SHA, build timestamp, included files, and checksums. Downstream jobs read the manifest to understand what they received without depending on inter-job outputs.
- Child pipeline
- In GitLab CI, a pipeline triggered by a parent pipeline via
trigger:with a dynamically generated YAML file. The child pipeline YAML can be generated from a JSON config usingjqin a.prestage job, enabling data-driven CI topology. - Context expression
- A GitHub Actions expression that accesses a context object —
github,runner,secrets,needs,steps,matrix, etc. — using dot notation inside{{ ... }}delimiters.fromJSON()andtoJSON()bridge the gap between context strings and structured data.
FAQ
How do I use JSON in a GitHub Actions matrix?
Use a two-job pattern: a generator job outputs a compact JSON array, and a consumer job reads it with fromJSON(). In the generator: echo "matrix=$(jq -c . versions.json)" >> $GITHUB_OUTPUT. In the consumer, declare needs: [generator] and set strategy: matrix: include: ${{ fromJSON(needs.generator.outputs.matrix) }}. The JSON array can contain scalars (node versions) or objects (with multiple fields like env and region). jq -c ensures single-line output required by $GITHUB_OUTPUT.
How do I store a JSON config as a GitHub Actions secret?
GitHub Secrets store plain strings. Serialize your JSON config to a compact string and paste it as the secret value in repository settings. For example, store {"host":"db.prod.example.com","port":5432,"ssl":true} as a secret named DB_CONFIG. In workflows, read individual fields with ${{ fromJSON(secrets.DB_CONFIG).host }}. For configs exceeding the 32 KB limit, base64-encode the JSON: base64 -w0 config.json, store the result as a secret, then decode it in CI with echo "$SECRET" | base64 -d > config.json.
How do I parse a JSON API response in a GitHub Actions step?
Use curl to fetch the response and pipe it to jq: STATUS=$(curl -s https://api.example.com/deploy/$ID | jq -r '.status'). To pass the result to subsequent steps, write it to $GITHUB_OUTPUT: echo "status=$STATUS" >> $GITHUB_OUTPUT. For the full response object, compact it: echo "response=$(curl -s $URL | jq -c .)" >> $GITHUB_OUTPUT. Then access fields in later steps with ${{ fromJSON(steps.STEP_ID.outputs.response).status }}. Use jq -e to set a non-zero exit code when a condition fails, automatically failing the step.
How do I validate JSON files in CI before deploying?
Install ajv-cli and run it against your JSON Schema before the deploy step: npx ajv-cli validate -s schema.json -d config.json. If validation fails, ajv-cli exits with a non-zero code and the workflow stops. For multiple files, use a glob: npx ajv-cli validate -s schema.json -d "configs/*.json". Place the validation step before any infrastructure provisioning to fail fast on config errors. See the JSON Schema testing in CI guide for advanced patterns.
Is jq available on GitHub Actions runners?
Yes. jq is pre-installed on all GitHub-hosted runners: ubuntu-latest, macos-latest, and windows-latest (via Git Bash). You do not need a setup step. On self-hosted runners, verify with jq --version or install via the package manager. Common patterns: jq -r '.version' package.json extracts a scalar, jq -c . data.json compacts for $GITHUB_OUTPUT, jq '[.[] | select(.active)]' filters arrays, and jq --arg v "$VAR" '.[$v]' injects shell variables safely. See jq filter examples for a full reference.
How do I inject environment-specific JSON config in CI?
Use a jq merge overlay pattern: keep a base.json and per-environment override files in the repository. In CI, merge them with jq -s '.[0] * .[1]' base.json $${ENV}.json > config.json. The * operator performs a deep merge — the right-hand object overrides matching keys. Pass the environment name via matrix or a workflow input. For secret injection into the merged config, use jq --arg secret "$SECRET" '.database.password = $secret'. See JSON config files reference for the full overlay pattern.
What is the maximum size of a GitHub Actions environment variable?
GitHub Actions limits environment variables (including $GITHUB_OUTPUT values) to 32 KB each. GitLab CI allows up to 512 KB per variable. If your JSON config exceeds these limits, store it as a file in the repository or as a workflow artifact. Another approach is base64 encoding: base64 -w0 large-config.json produces a longer but single-line string with no special characters. Decode it in the step that needs it: echo "$ENCODED" | base64 -d > config.json. For very large configs, use actions/upload-artifact and actions/download-artifact.
How do I use JSON in GitLab CI pipelines?
GitLab CI uses jq for JSON manipulation in script: blocks. For artifact reports, specify artifacts:reports:junit or artifacts:reports:coverage_report with a JSON or XML file path — GitLab renders these in the merge request UI. For dynamic child pipelines, generate a child pipeline YAML from a JSON config using jq in a .pre stage job and trigger it with trigger:include. GitLab CI variables support up to 512 KB, so larger JSON configs fit directly as variables. The artifacts:reports:dotenv format lets jobs pass extracted JSON fields as variables to downstream jobs.
Further reading and primary sources
- GitHub Actions Expressions — fromJSON and toJSON — Official GitHub docs on expression functions including fromJSON() and toJSON()
- jq Manual — Complete jq filter reference: selectors, iterators, functions, and streaming
- GitLab CI/CD Artifacts Reports — Official GitLab docs on artifacts:reports types including JUnit, SAST, and coverage
- ajv-cli npm package — Command-line JSON Schema validator for CI pipelines — validates files and exits non-zero on failure
- JSON in GitHub Actions (Jsonic) — Deep dive into fromJSON(), toJSON(), dynamic matrices, and jq in GitHub Actions workflows
- jq Filter Examples (Jsonic) — 30+ practical jq filter patterns for real-world JSON processing in CI and shell scripts