Skip to content

Commit 5a14035

Browse files
JPHutchinsclaude
andcommitted
feat: --github-matrix
Emit a GitHub Actions matrix as JSON from a camas matrix-bearing task, so the matrix definition lives once in tasks.py instead of being duplicated in workflow YAML. Output is the object-of-arrays form ({"PY": ["3.10", "3.11"]}), consumable as the whole matrix via `matrix: ${{ fromJSON(...) }}` or one axis at a time via `<axis>: ${{ fromJSON(...).<axis> }}`. TTY-aware: indented for interactive preview, compact one-liner for `>> $GITHUB_OUTPUT`. Validation: emitted JSON is tested against the GHA workflow schema's matrix subset (vendored from SchemaStore, sha dbc0fd17). Dogfooded in ci.yaml: the `check` job's hardcoded `python: ["3.10"..."3.15"]` is replaced by a `discover` job that runs `camas matrix --github-matrix`. `.python-version` becomes the single source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8a7d633 commit 5a14035

9 files changed

Lines changed: 696 additions & 4 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,24 @@ jobs:
3535
matrix={"PY": ("3.10", "3.11", "3.12", "3.13", "3.14", "3.15")},
3636
)' --effects='(Summary(SummaryOptions(Fixed(90))),)'
3737
38+
discover:
39+
runs-on: ubuntu-latest
40+
outputs:
41+
matrix: ${{ steps.emit.outputs.matrix }}
42+
steps:
43+
- uses: actions/checkout@v5
44+
45+
- uses: astral-sh/setup-uv@v6
46+
47+
- id: emit
48+
run: echo "matrix=$(uv run camas matrix --github-matrix)" >> $GITHUB_OUTPUT
49+
3850
check:
51+
needs: discover
3952
strategy:
4053
matrix:
4154
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
42-
python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
55+
python: ${{ fromJSON(needs.discover.outputs.matrix).PY }}
4356
fail-fast: false
4457
runs-on: ${{ matrix.os }}
4558
steps:

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ dev = [
8686
"tomli>=2",
8787
"typing_extensions>=4.12",
8888
"httpx>=0.28,<1",
89+
"jsonschema>=4,<5",
90+
"types-jsonschema",
8991
]
9092

9193
[tool.pytest.ini_options]

src/camas/main/dispatch.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
print_task_trees,
3333
print_tree,
3434
)
35+
from .github_matrix import emit as emit_github_matrix
3536
from .parser import RESERVED_FLAGS, build_parser
3637
from .state import EMPTY_STATE, LoadErr, LoadOk, TasksState
3738
from .tasks import load_tasks, load_tasks_from_py, name_scope_bindings, name_scope_effects
@@ -199,6 +200,19 @@ def dispatch(state: TasksState, argv: list[str] | None = None) -> None:
199200
except ValueError as e:
200201
print(f"error: {e}", file=sys.stderr)
201202
sys.exit(2)
203+
if args.github_matrix:
204+
if split.passthrough:
205+
print(
206+
"error: --github-matrix does not accept '--' passthrough args",
207+
file=sys.stderr,
208+
)
209+
sys.exit(2)
210+
try:
211+
print(emit_github_matrix(resolved, pretty=sys.stdout.isatty()))
212+
except ValueError as e:
213+
print(f"error: {e}", file=sys.stderr)
214+
sys.exit(2)
215+
sys.exit(0)
202216
try:
203217
task: Final = (
204218
apply_passthrough(resolved, split.passthrough)

src/camas/main/format.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ def print_task_help(name: str, task: TaskNode) -> None:
212212
matrix axes the user can override from the CLI."""
213213
axes = matrix_axes(task)
214214
axis_flags = "".join(f" [--{k} VAL[,VAL...]]" for k in axes)
215-
print(f"usage: camas {name} [-h] [--dry-run] [--effects EFFECTS]{axis_flags}")
215+
matrix_flag = " [--github-matrix]" if axes else ""
216+
print(f"usage: camas {name} [-h] [--dry-run]{matrix_flag} [--effects EFFECTS]{axis_flags}")
216217
if task.help is not None:
217218
print()
218219
print(task.help)

src/camas/main/github_matrix.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# SPDX-License-Identifier: MIT
2+
# SPDX-FileCopyrightText: 2026 JP Hutchins
3+
4+
"""``--github-matrix``: emit a GitHub Actions matrix as JSON.
5+
6+
Projects the task's matrix axes — collected from anywhere in the tree via
7+
:func:`camas.core.matrix.matrix_axes`, outermost-wins on duplicate keys —
8+
into the object-of-arrays form that GHA consumes natively::
9+
10+
{"PY": ["3.10", "3.11"], "OS": ["linux", "macos"]}
11+
12+
Use the whole object as the matrix::
13+
14+
matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
15+
16+
or one axis at a time, composed with other YAML-side axes::
17+
18+
matrix:
19+
os: [ubuntu-latest, macos-latest]
20+
PY: ${{ fromJSON(needs.discover.outputs.matrix).PY }}
21+
22+
CLI overrides (``--PY 3.13``) flow through the same ``override_matrix``
23+
pipeline used by the runner before emission, so the emitted JSON reflects
24+
exactly what the run would have executed.
25+
26+
Output is TTY-aware: indented for interactive preview, compact one-line
27+
for pipes — so ``camas matrix --github-matrix`` reads cleanly in a shell
28+
*and* ``$(camas matrix --github-matrix)`` works directly with
29+
``$GITHUB_OUTPUT``.
30+
"""
31+
32+
from __future__ import annotations
33+
34+
import json
35+
from collections.abc import Mapping
36+
37+
from ..core.matrix import matrix_axes
38+
from ..core.task import TaskNode
39+
40+
41+
def to_matrix_object(task: TaskNode) -> dict[str, list[str]]:
42+
"""Project a task's matrix axes into a GHA-compatible object-of-arrays.
43+
44+
Raises ``ValueError`` when the task has no matrix axes or any axis has no
45+
values — ``--github-matrix`` requires at least one cell to emit a workflow
46+
GHA will accept (the schema mandates ``minItems: 1`` on every axis array).
47+
48+
>>> from camas import Parallel, Task
49+
>>> to_matrix_object(Parallel(Task("test"), matrix={"PY": ("3.12", "3.13")}))
50+
{'PY': ['3.12', '3.13']}
51+
>>> to_matrix_object(Parallel(Task("t"), matrix={"PY": ("3.13",), "OS": ("linux",)}))
52+
{'PY': ['3.13'], 'OS': ['linux']}
53+
>>> to_matrix_object(Task("hi"))
54+
Traceback (most recent call last):
55+
...
56+
ValueError: task has no matrix axes; --github-matrix requires at least one
57+
>>> to_matrix_object(Parallel(Task("t"), matrix={"PY": ()}))
58+
Traceback (most recent call last):
59+
...
60+
ValueError: matrix axis 'PY' has no values
61+
"""
62+
axes: Mapping[str, tuple[str, ...]] = matrix_axes(task)
63+
if not axes:
64+
raise ValueError("task has no matrix axes; --github-matrix requires at least one")
65+
for name, values in axes.items():
66+
if not values:
67+
raise ValueError(f"matrix axis {name!r} has no values")
68+
return {name: list(values) for name, values in axes.items()}
69+
70+
71+
def format_matrix_json(matrix: Mapping[str, list[str]], *, pretty: bool) -> str:
72+
"""Serialize the matrix object to JSON.
73+
74+
Compact (no spaces, single line) when ``pretty`` is False — the canonical
75+
shape for ``echo "matrix=$(...)" >> $GITHUB_OUTPUT``. Indented two spaces
76+
when ``pretty`` is True — readable preview for interactive use.
77+
78+
>>> format_matrix_json({"PY": ["3.12", "3.13"]}, pretty=False)
79+
'{"PY":["3.12","3.13"]}'
80+
>>> print(format_matrix_json({"PY": ["3.12"]}, pretty=True))
81+
{
82+
"PY": [
83+
"3.12"
84+
]
85+
}
86+
"""
87+
if pretty:
88+
return json.dumps(matrix, indent=2)
89+
return json.dumps(matrix, separators=(",", ":"))
90+
91+
92+
def emit(task: TaskNode, *, pretty: bool) -> str:
93+
"""Compose :func:`to_matrix_object` and :func:`format_matrix_json`.
94+
95+
>>> from camas import Parallel, Task
96+
>>> emit(Parallel(Task("t"), matrix={"PY": ("3.12",)}), pretty=False)
97+
'{"PY":["3.12"]}'
98+
"""
99+
return format_matrix_json(to_matrix_object(task), pretty=pretty)

src/camas/main/parser.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,17 @@ def build_parser(state: TasksState = EMPTY_STATE) -> argparse.ArgumentParser:
9595
action="version",
9696
version=f"camas {importlib.metadata.version('camas')}",
9797
)
98-
parser.add_argument(
98+
preview = parser.add_mutually_exclusive_group()
99+
preview.add_argument(
99100
"--dry-run",
100101
action="store_true",
101102
help="print the task tree without executing",
102103
)
104+
preview.add_argument(
105+
"--github-matrix",
106+
action="store_true",
107+
help="emit GitHub Actions matrix JSON for the task's axes (consume via fromJSON)",
108+
)
103109
parser.add_argument(
104110
"--list",
105111
action="store_true",
@@ -133,7 +139,7 @@ def build_parser(state: TasksState = EMPTY_STATE) -> argparse.ArgumentParser:
133139

134140

135141
RESERVED_FLAGS: Final = frozenset(
136-
{"help", "version", "dry-run", "list", "tree", "check", "effects", "matrix"}
142+
{"help", "version", "dry-run", "github-matrix", "list", "tree", "check", "effects", "matrix"}
137143
)
138144

139145

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "github-actions-matrix-subset",
4+
"description": "Matrix subschema extracted verbatim from SchemaStore github-workflow.json (sha dbc0fd17b1c6b5f2fe151ffd0e504ca01c777151, fetched 2026-05-26). Source: https://github.com/SchemaStore/schemastore/blob/dbc0fd17b1c6b5f2fe151ffd0e504ca01c777151/src/schemas/json/github-workflow.json#L%23/definitions/matrix",
5+
"$ref": "#/definitions/matrix",
6+
"definitions": {
7+
"expressionSyntax": {
8+
"type": "string",
9+
"pattern": "^\\$\\{\\{(.|[\r\n])*\\}\\}$"
10+
},
11+
"configuration": {
12+
"oneOf": [
13+
{ "type": "string" },
14+
{ "type": "number" },
15+
{ "type": "boolean" },
16+
{
17+
"type": "object",
18+
"additionalProperties": { "$ref": "#/definitions/configuration" }
19+
},
20+
{
21+
"type": "array",
22+
"items": { "$ref": "#/definitions/configuration" }
23+
}
24+
]
25+
},
26+
"matrix": {
27+
"oneOf": [
28+
{
29+
"type": "object",
30+
"patternProperties": {
31+
"^(in|ex)clude$": {
32+
"oneOf": [
33+
{ "$ref": "#/definitions/expressionSyntax" },
34+
{
35+
"type": "array",
36+
"items": {
37+
"type": "object",
38+
"additionalProperties": { "$ref": "#/definitions/configuration" }
39+
},
40+
"minItems": 1
41+
}
42+
]
43+
}
44+
},
45+
"additionalProperties": {
46+
"oneOf": [
47+
{
48+
"type": "array",
49+
"items": { "$ref": "#/definitions/configuration" },
50+
"minItems": 1
51+
},
52+
{ "$ref": "#/definitions/expressionSyntax" }
53+
]
54+
},
55+
"minProperties": 1
56+
},
57+
{ "$ref": "#/definitions/expressionSyntax" }
58+
]
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)