A GitHub Action for centralized configuration management that enables you to define your workflow matrices once and reuse them everywhere. Stop duplicating configuration across multiple workflows!
The Problem: You need to deploy to multiple environments (dev, staging, prod) and run different workflows (plan, apply, test, lint). Without this action, you'd have to:
- Duplicate the same matrix configuration in every workflow file
- Update 5+ files when adding a new environment
- Risk inconsistencies between workflows
- Maintain hundreds of lines of repetitive YAML
The Solution: Define your matrix once in a config file and reference it in all workflows. Add a new environment? Just update one file.
One Config File (.github/matrix-config.json):
{
"settings": {
"dimension": "service",
"base_dir": "deploy"
},
"global": {
"aws_region": "us-east-1"
},
"environment": {
"dev": {
"aws_account_id": "111111111111"
},
"staging": {
"aws_account_id": "222222222222"
},
"prod": {
"aws_account_id": "333333333333",
"aws_region": "us-west-2"
}
},
"service": {
"api": { "port": "8080" },
"frontend": null
}
}This automatically expands to 6 matrix entries (sorted by environment by default):
[
{ "service": "api", "environment": "dev", "directory": "deploy/api", "aws_account_id": "111111111111", "aws_region": "us-east-1", "port": "8080" },
{ "service": "frontend", "environment": "dev", "directory": "deploy/frontend", "aws_account_id": "111111111111", "aws_region": "us-east-1" },
{ "service": "api", "environment": "prod", "directory": "deploy/api", "aws_account_id": "333333333333", "aws_region": "us-west-2", "port": "8080" },
{ "service": "frontend", "environment": "prod", "directory": "deploy/frontend", "aws_account_id": "333333333333", "aws_region": "us-west-2" },
{ "service": "api", "environment": "staging", "directory": "deploy/api", "aws_account_id": "222222222222", "aws_region": "us-east-1", "port": "8080" },
{ "service": "frontend", "environment": "staging", "directory": "deploy/frontend", "aws_account_id": "222222222222", "aws_region": "us-east-1" }
]Dimensions are defined as top-level keys (maps or arrays). The settings key holds action settings (dimension, base_dir, sort_by). The global key holds shared config values merged into every entry. Per-dimension-value configs (like aws_account_id per environment) are embedded directly in the dimension map. The directory field is automatically computed from base_dir and the primary dimension value. When the primary dimension is not present, directory falls back to base_dir alone.
Add a new service? Just add a key to the service map — instantly get 3 more environments!
Add a new environment? Just add its config block — all services automatically deploy there!
Multiple Jobs in the Same Workflow - All Reusing the Same Setup:
Terraform Workflow
name: Terraform Workflow
on:
push:
branches: [main]
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v5
- id: set-matrix
uses: DND-IT/action-config@v3
plan:
needs: setup
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.setup.outputs.matrix) }}
uses: DND-IT/github-workflows/.github/workflows/tf-plan.yaml@v3
with:
environment: ${{ matrix.environment }}
aws_account_id: ${{ matrix.aws_account_id }}
aws_region: ${{ matrix.aws_region }}
aws_oidc_role_arn: arn:aws:iam::${{ matrix.aws_account_id }}:role/cicd-iac
tf_dir: ${{ matrix.directory }}
tf_backend_config_files: ${{ matrix.directory }}/environments/${{ matrix.environment }}.s3.tfbackend
tf_var_files: ${{ matrix.directory }}/environments/${{ matrix.environment }}.tfvars
apply:
needs: [setup, plan]
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.setup.outputs.matrix) }}
uses: DND-IT/github-workflows/.github/workflows/tf-apply.yaml@v3
with:
environment: ${{ matrix.environment }}
aws_account_id: ${{ matrix.aws_account_id }}
aws_region: ${{ matrix.aws_region }}
aws_oidc_role_arn: arn:aws:iam::${{ matrix.aws_account_id }}:role/cicd-iac
tf_dir: ${{ matrix.directory }}
tf_backend_config_files: ${{ matrix.directory }}/environments/${{ matrix.environment }}.s3.tfbackend
tf_var_files: ${{ matrix.directory }}/environments/${{ matrix.environment }}.tfvarsCreate a configuration file in your repository (.github/matrix-config.json):
{
"environment": {
"dev": { "aws_account_id": "111111111111" },
"prod": { "aws_account_id": "222222222222" }
},
"service": {
"api": null,
"frontend": null
}
}jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
uses: DND-IT/action-config@v3
with:
config_path: '.github/matrix-config.yaml' # optional, this is the default
deploy:
needs: setup
strategy:
matrix:
include: ${{ fromJson(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- name: Deploy to ${{ matrix.environment }}
run: echo "Deploying ${{ matrix.service }} from ${{ matrix.directory }}"| Input | Description | Required | Default |
|---|---|---|---|
config_path |
Path to the configuration file (JSON or YAML) | No | .github/matrix-config.yaml |
dimension |
Override the dimension from config. Switches the primary dimension and removes the config dimension from the matrix. |
No | |
target |
Filter by dimension value(s), or switch dimension (see Dimension Selection). | No | |
environment |
Filter environments. Comma-separated for multiple (e.g. dev,prod) |
No | |
exclude |
JSON array of patterns to exclude (e.g. [{"service":"shared","environment":"dev"}]) |
No | |
include |
JSON array of entries to append (e.g. [{"service":"shared"}]) |
No | |
change_detection |
Filter matrix to only entries with file changes. Uses base_dir from settings to map file paths. Requires actions/checkout with fetch-depth: 0. |
No | false |
summary |
Write all output values to the GitHub Actions step summary for at-a-glance visibility. | No | true |
The target and environment inputs are convenience filters applied after the config file is expanded. The exclude and include inputs work the same way as their config file counterparts but are applied after them, allowing workflow-level overrides.
| Output | Description |
|---|---|
matrix |
JSON string containing the matrix configuration |
changes_detected |
Whether any entries have changes (true/false). Only meaningful when change_detection is true. |
config |
JSON object keyed by dimension values for direct field access via fromJson() (see Config Output) |
length |
Number of entries in the matrix (e.g. "4"). Useful for conditional jobs: if: needs.setup.outputs.length > 0 |
config_file |
Path to the configuration file that was actually read for this run (e.g. .github/matrix-config.yaml). |
| (flat keys) | When the matrix contains exactly one entry, each of its fields is also emitted as a flat output (e.g. aws_region, directory). |
The config output is a nested JSON object indexed by dimension values (sorted alphabetically by dimension key). This lets you look up any entry's fields directly without jq:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.config.outputs.matrix }}
config: ${{ steps.config.outputs.config }}
steps:
- uses: actions/checkout@v4
- id: config
uses: DND-IT/action-config@v3
deploy:
needs: setup
runs-on: ubuntu-latest
steps:
# Access any entry's fields by dimension values:
- run: echo "Dev API directory is ${{ fromJson(needs.setup.outputs.config).dev.api.directory }}"
- run: echo "Prod account is ${{ fromJson(needs.setup.outputs.config).prod.api.aws_account_id }}"When the matrix has exactly one entry (e.g. after filtering to a single service + environment), each field is also emitted as a flat output for convenience:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.cfg.outputs.matrix }}
aws_region: ${{ steps.cfg.outputs.aws_region }}
directory: ${{ steps.cfg.outputs.directory }}
steps:
- uses: actions/checkout@v4
- id: cfg
uses: DND-IT/action-config@v3
with:
target: api
environment: dev
deploy:
needs: setup
runs-on: ubuntu-latest
steps:
- run: echo "Region is ${{ needs.setup.outputs.aws_region }}"The configuration file must be a JSON or YAML object. There are four reserved top-level keys (settings, global, exclude, include). Everything else is a dimension.
| Key | Description |
|---|---|
settings |
Action settings: dimension, base_dir, sort_by. |
global |
Shared config values merged into every entry. |
exclude |
Array of patterns to exclude from the cartesian product. |
include |
Array of entries to append to the matrix. |
Any non-reserved top-level key is a dimension. Dimensions can be:
- Map dimensions — keys become dimension values, objects hold per-value config:
environment: dev: aws_account_id: "111111111111" prod: aws_account_id: "222222222222"
- Array dimensions — simple list of values:
region: - us-east-1 - eu-west-1
Both formats are supported and can be mixed in the same config.
The settings key holds action settings:
| Key | Description | Default |
|---|---|---|
dimension |
Name of the primary dimension (used for filtering via target input and change detection) |
"service" |
base_dir |
Base directory for building the directory output field and mapping file paths for change detection. When the dimension is not present in an entry, directory is set to base_dir alone. |
(empty) |
sort_by |
Array of keys to sort the matrix entries by | ["environment"] |
The global key holds shared config values merged into every matrix entry. For example, aws_region: us-east-1 in global gives every entry that value unless overridden by a per-dimension-value config.
For each matrix entry, values are merged in this order (later overrides earlier):
- Scalar top-level values — non-dimension, non-reserved top-level values
- Global config values — all keys from
global - Dimension values — e.g.
environment: dev,service: api - Per-dimension-value configs — in alphabetical dimension key order (e.g.
environmentbeforeservice)
JSON:
{
"environment": {
"dev": { "aws_account_id": "111111111111" },
"staging": { "aws_account_id": "222222222222" },
"prod": { "aws_account_id": "333333333333" }
},
"service": {
"api": null,
"frontend": null
}
}YAML:
environment:
dev:
aws_account_id: "111111111111"
staging:
aws_account_id: "222222222222"
prod:
aws_account_id: "333333333333"
service:
api:
frontend:How it works:
- Map dimensions (like
environmentandservice) have their keys become dimension values - Array dimensions have their items become dimension values
- Per-value config objects in map dimensions are merged into matching entries
- Non-array, non-map, non-reserved top-level values are copied to every matrix entry
- A
directoryfield is automatically added:base_dir/value(or justvalueifbase_diris not set). If the primary dimension key is absent from an entry,directoryfalls back tobase_dir. - Result: 2 services x 3 environments = 6 matrix entries
Use the global key to define values shared across all entries. Per-dimension-value configs override global values:
{
"global": {
"aws_region": "us-east-1",
"timeout": "30"
},
"environment": {
"dev": { "aws_account_id": "111111111111" },
"prod": {
"aws_account_id": "222222222222",
"aws_region": "us-west-2"
}
},
"service": { "api": null }
}This produces:
api/dev:aws_region: "us-east-1"(from global),timeout: "30",aws_account_id: "111111111111"api/prod:aws_region: "us-west-2"(overrides global),timeout: "30",aws_account_id: "222222222222"
Map dimensions can embed config per value. These are merged in alphabetical dimension key order:
{
"environment": {
"dev": { "aws_account_id": "111111111111" },
"prod": { "aws_account_id": "222222222222" }
},
"service": {
"api": { "port": "8080" },
"frontend": { "port": "3000" }
}
}For the api/dev entry: first environment:dev config is applied (aws_account_id), then service:api config is applied (port). If both dimensions set the same key, the later one alphabetically wins.
Matrix entries are sorted by ["environment"] by default, which groups entries by environment. Override with sort_by in settings:
{
"settings": {
"sort_by": ["service", "environment"]
},
"environment": {
"dev": { "aws_account_id": "111111111111" },
"prod": { "aws_account_id": "222222222222" }
},
"service": { "frontend": null, "api": null }
}With the default sort (["environment"]), entries are grouped as: all dev entries, then all prod entries.
With ["service", "environment"], entries are grouped as: api/dev, api/prod, frontend/dev, frontend/prod.
The dimension name is fully configurable. You can use service, app, component, or any name that fits your project:
{
"settings": {
"dimension": "app",
"base_dir": "apps"
},
"environment": {
"dev": { "cluster": "dev-cluster" },
"prod": { "cluster": "prod-cluster" }
},
"app": { "web": null, "worker": null, "cron": null }
}This produces entries like {"app": "web", "environment": "dev", "directory": "apps/web", "cluster": "dev-cluster"}.
Add a region dimension to deploy each service across multiple AWS regions:
settings:
dimension: service
base_dir: deploy
environment:
dev:
aws_account_id: "111111111111"
prod:
aws_account_id: "222222222222"
service:
api:
port: "8080"
frontend:
region:
- eu-central-1
- us-east-1This produces 8 entries (2 services × 2 environments × 2 regions). Each entry includes a region field you can use in your workflow:
steps:
- name: Deploy
run: |
aws configure set region ${{ matrix.region }}
# deploy ${{ matrix.service }} to ${{ matrix.environment }} in ${{ matrix.region }}To set region-specific config, use a map dimension instead of an array:
region:
eu-central-1:
aws_vpc_id: vpc-abc123
us-east-1:
aws_vpc_id: vpc-def456You can also combine regions with exclude to skip certain combinations (e.g. frontend only in eu-central-1):
exclude:
- service: frontend
region: us-east-1Use exclude to remove specific combinations from the cartesian product:
{
"exclude": [
{ "service": "shared", "environment": "dev" }
],
"environment": {
"dev": { "aws_account_id": "111111111111" },
"prod": { "aws_account_id": "222222222222" }
},
"service": { "api": null, "frontend": null, "shared": null }
}This produces 5 entries (3 services x 2 envs = 6, minus shared/dev).
Each exclude entry is a partial match — any matrix item matching all the key/value pairs in the pattern is removed.
Use include to append standalone entries that bypass the cartesian product:
{
"include": [
{ "service": "shared", "aws_account_id": "333333333333" }
],
"environment": {
"dev": { "aws_account_id": "111111111111" },
"prod": { "aws_account_id": "222222222222" }
},
"service": { "api": null, "frontend": null }
}This produces 5 entries: the 4 from the cartesian product, plus the shared entry appended at the end (with no environment field).
You can combine both to fully control the matrix:
{
"exclude": [
{ "service": "shared" }
],
"include": [
{ "service": "shared", "aws_account_id": "333333333333" }
],
"environment": {
"dev": { "aws_account_id": "111111111111" },
"prod": { "aws_account_id": "222222222222" }
},
"service": { "api": null, "shared": null }
}This removes all shared entries from the cartesian product (both shared/dev and shared/prod), then appends a single shared entry without an environment.
The target, environment, exclude, and include inputs let you filter at the workflow level without changing the config file. This is especially useful with workflow_dispatch:
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
type: choice
options:
- ''
- dev
- staging
- prod
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
uses: DND-IT/action-config@v3
with:
environment: ${{ inputs.environment }}
deploy:
needs: setup
strategy:
matrix:
include: ${{ fromJson(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ matrix.service }} to ${{ matrix.environment }}"When triggered manually with environment: prod, only prod entries are included. When left empty, all environments are included.
When your config defines multiple primary dimensions (e.g. service and terraform), you can select which dimension to use from the workflow level without changing the config file. This is useful when different workflows operate on different dimensions of the same config.
There are two ways to select a dimension:
1. Explicit dimension input — directly overrides the config's dimension. The config's original dimension is removed from the cross-product:
- uses: DND-IT/action-config@v3
with:
dimension: terraform # switch to terraform dimension, remove service2. target shorthand — if target is a single value that matches a dimension name (and is NOT a value of the current dimension), it automatically switches dimensions:
- uses: DND-IT/action-config@v3
with:
target: terraform # auto-detects as dimension name → same as dimension: terraformCombined: select dimension + filter within it:
- uses: DND-IT/action-config@v3
with:
dimension: terraform
target: infra # filter terraform=infra
environment: dev # also filter environment=devResolution order:
- If
dimensioninput is provided and differs from config → override, remove old dimension - Else if
targetis a single value matching a dimension name (and NOT a value of the currentdimension) → same switch - Otherwise →
targetfilters values within the currentdimension(default behavior)
By default, matrix jobs run in parallel. To run them one at a time, set max-parallel: 1 in the strategy:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
uses: DND-IT/action-config@v3
deploy:
needs: setup
strategy:
max-parallel: 1
matrix:
include: ${{ fromJson(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ matrix.service }} to ${{ matrix.environment }}"Note: GitHub Actions does not guarantee the execution order of matrix entries when using
max-parallel: 1. If you need a strict order (e.g., deploy todevbeforeprod), split them into separate jobs withneedsdependencies.
See the example workflow and example configuration files:
This action is written in Go and runs as a Docker container. It:
- Reads the specified configuration file
- Parses the
settingsandglobalblocks and dimension maps/arrays - Expands the configuration into a cartesian product matrix
- Applies exclude/include rules and filters
- Adds the
directoryfield to each entry - Outputs the configuration as a JSON string for use in matrix strategies
Run tests locally:
go test -v -race ./...go build -o action-config ./cmd/action-configThis action uses semantic-release for automated versioning based on conventional commits. When you push to main, a new release is automatically created if there are significant changes.
Use conventional commits to automatically determine the version bump:
Triggers Release:
feat:- New feature (minor version bump, e.g., 1.0.0 -> 1.1.0)fix:- Bug fix (patch version bump, e.g., 1.0.0 -> 1.0.1)perf:- Performance improvement (patch version bump)revert:- Revert changes (patch version bump)BREAKING CHANGE:- Breaking change (major version bump, e.g., 1.0.0 -> 2.0.0)
No Release (documentation only):
docs:- Documentation changesrefactor:- Code refactoringstyle:- Code style changeschore:- Maintenance taskstest:- Test updatesbuild:- Build system changesci:- CI/CD changes
The release workflow automatically updates version aliases:
v3- Always points to the latest v3.x.x releasev3.1- Always points to the latest v3.1.x release
This allows users to pin to major or minor versions:
- uses: DND-IT/action-config@v3 # Always gets latest v3.x.x
- uses: DND-IT/action-config@v3.1 # Always gets latest v3.1.x
- uses: DND-IT/action-config@v3.1.0 # Pinned to specific versionMIT
Maintained by DAI