Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ between workflows and shell scripts:
```yaml
# .github/workflows/matrix/docker-images-linux.json:
# [ {"distro": "ubuntu22", "arch": "x64"}, ... ]
- uses: ./scripts/actions/matrix-builder
- uses: ./.github/actions/matrix-builder
with:
matrix-file: .github/workflows/matrix/docker-images-linux.json
rules: |
Expand Down Expand Up @@ -129,7 +129,7 @@ literal string). Truthiness:
- YAML `true` → truthy; `false` / `null` / missing → falsy.
- Strings: trim + lowercase; `""`, `"false"`, `"0"`, `"no"`, `"off"` →
falsy; anything else → truthy.
- Numbers: standard JS truthiness.
- Numbers: zero is falsy, anything else truthy.

Rules with a falsy `if:` are skipped entirely.

Expand Down Expand Up @@ -173,7 +173,7 @@ key with the matching value. Keys not present in an entry don't match
### 1. Simple disable-flag filtering

```yaml
- uses: ./scripts/actions/matrix-builder
- uses: ./.github/actions/matrix-builder
id: m
with:
matrix: |
Expand Down Expand Up @@ -213,7 +213,7 @@ Declare both axes; group the per-axis attributes under list-form `extend`
rules.

```yaml
- uses: ./scripts/actions/matrix-builder
- uses: ./.github/actions/matrix-builder
id: m
with:
matrix: |
Expand Down Expand Up @@ -265,7 +265,7 @@ would survive.
### 4. Building a matrix entirely from rules

```yaml
- uses: ./scripts/actions/matrix-builder
- uses: ./.github/actions/matrix-builder
with:
matrix: '{}'
rules: |
Expand All @@ -283,7 +283,7 @@ appends a fresh entry.
### 5. Apply a default to every row

```yaml
- uses: ./scripts/actions/matrix-builder
- uses: ./.github/actions/matrix-builder
with:
matrix: |
platform: [x86_64, aarch64]
Expand All @@ -294,34 +294,16 @@ appends a fresh entry.

`extend` with no axis keys attaches `compiler: clang` to every entry.

## Building and contributing
## Layout

This is a JavaScript action. The runtime entry point is `dist/index.js`,
generated by [`@vercel/ncc`](https://github.com/vercel/ncc) from `src/`
and `lib/`. After editing source, rebuild and commit the bundle:
This is a composite action. Its single step converts each input to JSON
with `yq -o json .` and runs `matrix_builder.py` with the base matrix on
stdin and the rules in `--rules`; the script appends the `matrix` output to
`GITHUB_OUTPUT`. Both `yq` and `python3` come from the runner's PATH.

```bash
cd MeshLib/scripts/actions/matrix-builder
npm ci
npm run build # rebuilds dist/index.js — commit the result
npm test # runs node --test against tests/
```

Layout:

- `action.yml` — action metadata (inputs, outputs, runtime).
- `src/index.js` — entry point: parses inputs, calls the engine, sets
the output.
- `lib/engine.js` — pure rules engine with JSDoc on every export.
Read this for the canonical semantics.
- `dist/index.js` — bundled artifact GitHub actually runs. Regenerate
whenever `src/` or `lib/` changes.
- `tests/engine.test.js` — `node --test` suite covering each rule
type, `if:` truthiness, ordering, and parity snapshots against the
original `pip-build.yml` and `prepare-images.yml` jq pipelines.
- `tests/action.test.js` — runs `src/index.js` as a child process with
`INPUT_*` env vars, covering input handling: `matrix-file`
loading and the input-validation errors.
- `tests/fixtures/` — captured jq outputs (`build-*.json`,
`test-*.json`, `docker-linux-*.json`) for the parity snapshots, plus
sample matrix files for `matrix-file` tests.
- `action.yml` — action metadata (inputs, outputs) and the step above.
- `matrix_builder.py` — the rules engine and the script entry point. Read
this for the canonical semantics.
- `tests/test_matrix_builder.py` — `unittest` suite for the engine, the
script's command line, and the step body run through `bash` and `yq`.
Run from this directory: `python3 -m unittest discover -s tests`.
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,25 @@ inputs:
outputs:
matrix:
description: JSON-encoded array of the resulting matrix combinations.
value: ${{ steps.build.outputs.matrix }}

runs:
using: node24
main: dist/index.js
using: composite
steps:
- id: build
shell: bash
env:
MATRIX: ${{ inputs.matrix }}
MATRIX_FILE: ${{ inputs.matrix-file }}
RULES: ${{ inputs.rules }}
run: |
if [ -n "$MATRIX_FILE" ] && [ -n "$MATRIX" ]; then
echo "::error::'matrix' and 'matrix-file' are mutually exclusive"
exit 1
fi
if [ -n "$MATRIX_FILE" ]; then
MATRIX=$(cat "$MATRIX_FILE")
fi
MATRIX=$(yq -o json . <<<"$MATRIX")
RULES=$(yq -o json . <<<"$RULES")
echo "$MATRIX" | python3 "${{ github.action_path }}/matrix_builder.py" --rules="$RULES" -
145 changes: 145 additions & 0 deletions .github/actions/matrix-builder/matrix_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""
Build a GitHub Actions matrix from a base matrix and an ordered list of
include / extend / exclude rules (see README.md for the grammar).

The base matrix is read as JSON, either an axis map or a list of entries,
from the file named by the positional argument, or from stdin when that
argument is '-'; the rules arrive as a JSON list in --rules. The action step
produces both with yq:

MATRIX=$(yq -o json . <<<"$MATRIX")
RULES=$(yq -o json . <<<"$RULES")
echo "$MATRIX" | python3 matrix_builder.py --rules="$RULES" -

The resulting combinations are appended to the file named by GITHUB_OUTPUT
as `matrix=<json>`.
"""

import argparse
import json
import os
import sys


def cartesian(axes):
if not axes:
return []
entries = [{}]
for name, values in axes.items():
if not isinstance(values, list):
values = [values]
entries = [{**entry, name: value} for entry in entries for value in values]
return entries


def is_truthy(value):
if isinstance(value, str):
return value.strip().lower() not in ('', 'false', '0', 'no', 'off')
return bool(value)


def same_value(a, b):
return isinstance(a, bool) == isinstance(b, bool) and a == b


def matches_all(entry, criteria):
return all(key in entry and same_value(entry[key], value) for key, value in criteria.items())


def split_rule(rule, axis_keys):
axis_crit = {k: v for k, v in rule.items() if k in axis_keys}
extra = {k: v for k, v in rule.items() if k not in axis_keys}
return axis_crit, extra


def apply_include(entries, rule, axis_keys):
axis_crit, extra = split_rule(rule, axis_keys)
if not axis_crit:
return [*entries, dict(extra)]
matched = [matches_all(entry, axis_crit) for entry in entries]
out = [{**entry, **extra} if hit else entry for entry, hit in zip(entries, matched)]
if not any(matched):
out.append({**axis_crit, **extra})
return out


def apply_extend(entries, rule, axis_keys):
axis_crit, extra = split_rule(rule, axis_keys)
return [{**entry, **extra} if matches_all(entry, axis_crit) else entry for entry in entries]


def apply_exclude(entries, rule):
return [entry for entry in entries if not matches_all(entry, rule)]


def normalize_bodies(body, kind, rule_index):
if isinstance(body, list):
for j, item in enumerate(body):
if not isinstance(item, dict):
raise ValueError(f"rule #{rule_index} '{kind}' entry #{j} must be an object")
return body
if not isinstance(body, dict):
raise ValueError(f"rule #{rule_index} '{kind}' must be an object or a list of objects")
return [body]


def build_matrix(base_matrix, rules):
if isinstance(base_matrix, list):
for i, entry in enumerate(base_matrix):
if not isinstance(entry, dict):
raise ValueError(f'base matrix entry #{i} must be an object')
axis_keys = {key for entry in base_matrix for key in entry}
entries = [dict(entry) for entry in base_matrix]
elif isinstance(base_matrix, dict):
axis_keys = set(base_matrix)
entries = cartesian(base_matrix)
else:
raise ValueError("'matrix' must be a map of axis names to value lists or a list of entries")
if not isinstance(rules, list):
raise ValueError("'rules' must be a list")

for i, rule in enumerate(rules):
if not isinstance(rule, dict):
raise ValueError(f'rule #{i} is not an object')
if 'if' in rule and not is_truthy(rule['if']):
continue
kinds = [kind for kind in ('include', 'extend', 'exclude') if kind in rule]
if len(kinds) != 1:
found = ', '.join(kinds) if kinds else 'none'
raise ValueError(
f"rule #{i} must have exactly one of 'include', 'extend', 'exclude' (found: {found})"
)
kind = kinds[0]
for body in normalize_bodies(rule[kind], kind, i):
if kind == 'include':
entries = apply_include(entries, body, axis_keys)
elif kind == 'extend':
entries = apply_extend(entries, body, axis_keys)
else:
entries = apply_exclude(entries, body)
return entries


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('matrix', type=argparse.FileType(), help="JSON base matrix file, or '-' for stdin")
parser.add_argument('--rules', required=True, help='JSON list of rules, applied in order')
args = parser.parse_args()
with args.matrix as f:
matrix = json.load(f)
rules = json.loads(args.rules)
try:
result = build_matrix({} if matrix is None else matrix, [] if rules is None else rules)
except ValueError as e:
print(f'::error::{e}')
return 1
with open(os.environ['GITHUB_OUTPUT'], 'a') as out:
print(f"matrix={json.dumps(result, separators=(',', ':'))}", file=out)
print(f'::group::matrix-builder: {len(result)} combination(s)')
print(json.dumps(result, indent=2))
print('::endgroup::')
return 0


if __name__ == '__main__':
sys.exit(main())
Loading
Loading