Supporting dynamic multiline variables in GitHub Actions
One of the challenges when building reusable GitHub Actions workflows is handling multiline variables that need to be passed from configuration files to actions. This is particularly common when working with actions that require formatted input strings, like the hashicorp/vault-action which expects secrets in a specific multiline format.
In this post, I plan to store multiline variables in JSON arrays and dynamically load them in GitHub Actions workflows with support for prefixes to reduce duplication.
What are we trying to solve?¶
Some GitHub Actions require multiline input in a specific format. For example:
path/to/resource1 key1 | OUTPUT_VAR1 ;
path/to/resource2 key2 | OUTPUT_VAR2 ;
path/to/resource3 key3 | OUTPUT_VAR3
When building reusable workflows, hardcoding these values directly in the workflow YAML creates several problems:
- ❌ Not reusable - Different projects need different values
- ❌ Hard to maintain - Changes require workflow modifications
- ❌ Repetitive - Common path prefixes are duplicated
- ❌ Not project-specific - Each project should define its own configuration
A Possible Solution¶
A possible solution is to store these values in a JSON configuration file and dynamically load them at runtime. Additionally, we can support an optional prefix to reduce duplication when all entries share a common base path.
Configuration Structure¶
We'll use a JSON structure with two fields:
prefix(optional) - A common base path prepended to all entriesentries(required) - An array of path strings
With Prefix (Recommended):
{
"config": {
"prefix": "path/to/common/base/",
"entries": [
"service1/resource key1 | OUTPUT_VAR1",
"service2/resource key2 | OUTPUT_VAR2",
"service3/resource key3 | OUTPUT_VAR3"
]
}
}
Without Prefix (Full Paths):
{
"config": {
"entries": [
"path/to/service1/resource key1 | OUTPUT_VAR1",
"path/to/service2/resource key2 | OUTPUT_VAR2",
"different/base/path/resource key3 | OUTPUT_VAR3"
]
}
}
Benefits of This Approach¶
✅ Cleaner configuration - No repeated paths when using prefix
✅ Project-specific - Each project defines its own configuration
✅ Version controlled - Configuration tracked in git
✅ Easy to maintain - Change config file, not workflow
✅ Flexible - Works with or without prefix
Implementation¶
Step 1: Create Your Configuration File¶
Create a file like project-config.json:
{
"project_name": "my-project",
"version": "1.0.0",
"config": {
"prefix": "path/to/common/base/",
"entries": [
"service1/resource key1 | SERVICE1_VAR",
"service2/resource key2 | SERVICE2_VAR",
"service3/resource key3 | SERVICE3_VAR",
"api/endpoint key | API_KEY"
]
}
}
Step 2: Add Preparation Step to Your Workflow¶
Before using the multiline variable, add a step to read and format it:
- name: Prepare multiline variable from config
id: prepare-config
env:
CONFIG_FILE: ${{ inputs.CONFIG_FILE || './project-config.json' }}
JSON_PATH_ENTRIES: '.config.entries'
JSON_PATH_PREFIX: '.config.prefix'
run: |
echo "Reading configuration from: $CONFIG_FILE"
# Check if entries exist in the config file
if jq -e "${JSON_PATH_ENTRIES}" "$CONFIG_FILE" > /dev/null 2>&1; then
echo "✓ Found entries at ${JSON_PATH_ENTRIES}"
# Check if a prefix is defined
PREFIX=""
if jq -e "${JSON_PATH_PREFIX}" "$CONFIG_FILE" > /dev/null 2>&1; then
PREFIX=$(jq -r "${JSON_PATH_PREFIX}" "$CONFIG_FILE")
if [[ -n "$PREFIX" && "$PREFIX" != "null" ]]; then
echo "✓ Using prefix: $PREFIX"
fi
fi
# Build the multiline variable with or without prefix
if [[ -n "$PREFIX" && "$PREFIX" != "null" ]]; then
# Prepend prefix to each entry
FORMATTED_OUTPUT=$(jq -r "${JSON_PATH_ENTRIES} | map(\"${PREFIX}\" + .) | join(\" ;\\n\")" "$CONFIG_FILE")
else
# Use entries as-is
FORMATTED_OUTPUT=$(jq -r "${JSON_PATH_ENTRIES} | join(\" ;\\n\")" "$CONFIG_FILE")
fi
# Ensure it ends with a semicolon
if [[ ! "$FORMATTED_OUTPUT" =~ \;[[:space:]]*$ ]]; then
FORMATTED_OUTPUT="${FORMATTED_OUTPUT} ;"
fi
else
echo "✗ Error: entries not found at ${JSON_PATH_ENTRIES}"
exit 1
fi
# Store in output using multiline heredoc
echo "output<<EOF" >> $GITHUB_OUTPUT
echo "$FORMATTED_OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
Step 3: Use the Dynamic Output¶
Now you can use the formatted multiline variable in your action:
- name: Use the multiline variable
uses: some-action/that-needs-multiline@v1
with:
formatted_input: ${{ steps.prepare-config.outputs.output }}
How It Works¶
Transformation Process¶
The workflow step uses jq (JSON processor) to:
- Read the prefix (if defined) from the JSON file
- Read the entries array from the JSON file
- Prepend the prefix to each entry using
jq'smap()function - Join entries with semicolons and newlines
- Store as multiline output using GitHub Actions heredoc syntax
Visual Flow¶
JSON Configuration
├── prefix: "path/to/common/base/"
└── entries: [
"service1/resource key | VAR1",
"service2/resource key | VAR2"
]
↓ [jq processes the JSON]
Step 1: Extract prefix
├── PREFIX = "path/to/common/base/"
Step 2: Map prefix to each entry
├── jq: map("${PREFIX}" + .)
Step 3: Join with semicolons
├── jq: join(" ;\n")
↓ [Output formatted string]
path/to/common/base/service1/resource key | VAR1 ;
path/to/common/base/service2/resource key | VAR2
Other Features¶
Configurable JSON Paths¶
You can customize where entries are stored by setting environment variables:
This allows you to store configuration anywhere in your JSON structure:
Backward Compatibility¶
You can add fallback logic to support multiple configuration formats:
run: |
# Try new format
if jq -e '.config.entries' "$CONFIG_FILE" > /dev/null 2>&1; then
# Process new format
...
# Try legacy format
elif jq -e '.legacy_entries' "$CONFIG_FILE" > /dev/null 2>&1; then
# Process legacy format
FORMATTED_OUTPUT=$(jq -r '.legacy_entries | join(" ;\n")' "$CONFIG_FILE")
else
# Use hardcoded defaults
FORMATTED_OUTPUT="default/path key | DEFAULT"
fi
Workflow Inputs for Testing¶
Add workflow inputs to test different configurations easily:
on:
workflow_dispatch:
inputs:
config_file:
description: 'Path to configuration file'
default: './project-config.json'
required: false
type: string
json_path_entries:
description: 'JSON path to entries array'
default: '.config.entries'
required: false
type: string
json_path_prefix:
description: 'JSON path to optional prefix'
default: '.config.prefix'
required: false
type: string
Complete Working Example¶
Here's a complete reusable workflow example:
name: Reusable Workflow with Dynamic Config
on:
workflow_call:
inputs:
CONFIG_FILE:
description: 'Path to JSON configuration file'
default: './project-config.json'
required: true
type: string
jobs:
process-dynamic-config:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Prepare multiline variable from config
id: prepare-config
env:
CONFIG_FILE: ${{ inputs.CONFIG_FILE }}
JSON_PATH_ENTRIES: '.config.entries'
JSON_PATH_PREFIX: '.config.prefix'
run: |
echo "Reading configuration from: $CONFIG_FILE"
if jq -e "${JSON_PATH_ENTRIES}" "$CONFIG_FILE" > /dev/null 2>&1; then
echo "✓ Found entries at ${JSON_PATH_ENTRIES}"
PREFIX=""
if jq -e "${JSON_PATH_PREFIX}" "$CONFIG_FILE" > /dev/null 2>&1; then
PREFIX=$(jq -r "${JSON_PATH_PREFIX}" "$CONFIG_FILE")
if [[ -n "$PREFIX" && "$PREFIX" != "null" ]]; then
echo "✓ Using prefix: $PREFIX"
fi
fi
if [[ -n "$PREFIX" && "$PREFIX" != "null" ]]; then
FORMATTED_OUTPUT=$(jq -r "${JSON_PATH_ENTRIES} | map(\"${PREFIX}\" + .) | join(\" ;\\n\")" "$CONFIG_FILE")
else
FORMATTED_OUTPUT=$(jq -r "${JSON_PATH_ENTRIES} | join(\" ;\\n\")" "$CONFIG_FILE")
fi
[[ ! "$FORMATTED_OUTPUT" =~ \;[[:space:]]*$ ]] && FORMATTED_OUTPUT="${FORMATTED_OUTPUT} ;"
else
echo "✗ Error: entries not found"
exit 1
fi
echo "output<<EOF" >> $GITHUB_OUTPUT
echo "$FORMATTED_OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Display formatted output
run: |
echo "Formatted multiline variable:"
echo "${{ steps.prepare-config.outputs.output }}"
- name: Use with an action
uses: hashicorp/vault-action@v3
with:
# Example: the vault-action expects this specific format
secrets: ${{ steps.prepare-config.outputs.output }}
Calling the Reusable Workflow¶
name: My Project Build
on:
push:
branches: [main]
jobs:
build:
uses: ./.github/workflows/reusable-workflow.yml
with:
CONFIG_FILE: './my-project-config.json'
Key Techniques¶
Multiline Output in GitHub Actions¶
GitHub Actions uses heredoc syntax for multiline outputs:
echo "output<<EOF" >> $GITHUB_OUTPUT
echo "$MULTILINE_CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
This preserves newlines and special characters in the output.
JQ Map Function¶
The map() function in jq transforms each element in an array:
# Input: ["item1", "item2"]
jq -r '.entries | map("PREFIX" + .) | join(" ;\n")'
# Output: PREFIXitem1 ;
# PREFIXitem2
JSON Processing with jq¶
Key jq operations used:
jq -e- Exit with status based on expression result (for testing existence)jq -r- Raw output (without JSON quotes)map()- Transform each array elementjoin()- Concatenate array elements with separator
Testing and Debugging¶
Enable Debug Mode¶
Add a debug step to see the formatted output:
- name: Debug - Show formatted output
if: runner.debug == '1'
run: echo "${{ steps.prepare-config.outputs.output }}"
Run with debug logging:
Validate Configuration Locally¶
Test your JSON transformation locally before running in CI:
# Test reading entries
jq '.config.entries' project-config.json
# Test with prefix
jq -r '.config.entries | map("PREFIX" + .) | join(" ;\n")' project-config.json
# Full test
PREFIX=$(jq -r '.config.prefix' project-config.json)
jq -r ".config.entries | map(\"${PREFIX}\" + .) | join(\" ;\\n\")" project-config.json
Best Practices¶
- Use prefix for cleaner configs - If most entries share a common base path, use the prefix feature
- Validate JSON syntax - Use a JSON validator or
jqto verify your config files - Add error handling - Include fallbacks for missing configuration
- Document your format - Add comments in your JSON explaining the structure
- Test locally first - Validate transformations with
jqbefore running in CI - Use workflow inputs - Make paths configurable for flexibility
- Version your configs - Track configuration changes in git
Use Cases¶
This pattern is useful when you need to:
- Pass formatted lists to GitHub Actions that require specific input formats
- Maintain project-specific configurations for reusable workflows
- Reduce duplication in configuration files
- Support multiple projects with different requirements
- Keep workflows DRY (Don't Repeat Yourself)
- Version control your workflow configurations
Conclusion¶
By storing multiline variables in JSON arrays and processing them with jq, you can build highly flexible and reusable GitHub Actions workflows. The optional prefix feature significantly reduces duplication and makes configurations easier to read and maintain.
This approach is particularly powerful for:
- Reusable workflows - One workflow, many projects
- Configuration-driven pipelines - Change config, not code
- Maintainability - Centralized, version-controlled configuration
- Flexibility - Works with or without prefixes
The key takeaways:
- Store configuration in JSON for easy parsing
- Use
jqto transform and format data - Leverage GitHub Actions heredoc for multiline outputs
- Add prefix support to reduce duplication
- Include fallbacks for backward compatibility
With this pattern, your GitHub Actions workflows become more maintainable, flexible, and easier to reuse across multiple projects.
References¶
Resources: