feat(#435): expand workflow-policy linter to full Cartesian matrix cross-product (#1050)

`expandRunsOn` enumerated a multi-axis `strategy.matrix` one key at a time,
producing partial realization contexts. A true `os × shell` matrix therefore
left `${{ matrix.shell }}` unresolvable against any `{ os: ... }`-only context,
firing spurious `UNRESOLVABLE_MATRIX` violations and leaving shell-pinning
coverage incomplete on Cartesian jobs.

Enumerate the full GitHub Actions cross-product of all base-list matrix keys
(every `matrix.<k>` array, excluding the `include`/`exclude` control keys) via
a named `cartesianProduct` helper. Each realization's context now carries a
value for every matrix key, so `${{ matrix.<key> }}` resolves per realization.
Single-axis matrices keep byte-for-byte identical output; only multi-axis
matrices change shape. The `include` and `exclude` blocks are unchanged
(full tuple-aware exclude is a documented out-of-scope follow-up).

Tests: updated the `os × shell` test to assert post-fix behavior (4 step
realizations, 2 WRONG_SHELL_FOR_OS, 0 UNRESOLVABLE_MATRIX); added a compliant
`os × node-version` cross-product test; added a fast-check property test that
the realization count equals the product of axis lengths and that no axis key
is dropped from any context.

Closes #435

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-11 13:36:47 -04:00
committed by GitHub
parent 9e3b056b15
commit cec7e704d6
2 changed files with 187 additions and 42 deletions

View File

@@ -36,6 +36,26 @@ function runnerDefault(runner) {
return 'bash'; // ubuntu-* and macos-* both default to bash on GHA
}
// ---------------------------------------------------------------------------
// Matrix expansion helpers
// ---------------------------------------------------------------------------
// Build the GitHub Actions Cartesian product of the given base-list matrix
// keys. Returns one context object per realized job, each mapping every key
// to a stringified value. A single key yields one context per value (identical
// to the legacy base-list expansion); N keys yield the full cross-product.
// Values are stringified to match runner-label comparison and matrix-expression
// resolution, which operate on strings.
function cartesianProduct(keys, matrix) {
return keys.reduce(
(contexts, key) =>
contexts.flatMap((context) =>
matrix[key].map((value) => ({ ...context, [key]: String(value) })),
),
[{}],
);
}
// ---------------------------------------------------------------------------
// Matrix expansion
// ---------------------------------------------------------------------------
@@ -88,19 +108,32 @@ function expandRunsOn(runsOnRaw, matrix) {
}
}
// Collect values from matrix.<key> list (e.g. matrix.os: [ubuntu, macos])
// These base-list entries have no extra context beyond the key itself.
// Each entry is pushed unconditionally — deduplicating by runner alone
// would collapse distinct Cartesian rows (e.g. duplicate os values paired
// with different shell values) and hide policy violations on later rows.
// GitHub Actions realizes one job per element of the Cartesian product of all
// base-list matrix keys (every matrix.<k> that is an array, excluding the
// include/exclude control keys), and each realized job's context carries a
// value for EVERY matrix key — so ${{ matrix.<key> }} references (e.g. in a
// shell: field) resolve against any realization, not only the runs-on key.
// Single-axis matrices yield exactly one realization per value, identical to
// the prior behavior; only multi-axis matrices change shape.
// Each realization is pushed unconditionally — deduplicating by runner label
// would collapse distinct Cartesian rows (e.g. matrix.os: [macos-latest,
// macos-latest] paired with different shells) and hide policy violations.
if (Array.isArray(matrix[key])) {
for (const val of matrix[key]) {
const runner = String(val);
realizations.push({ runner, resolvable: true, context: { [key]: runner } });
const baseListKeys = Object.keys(matrix).filter(
(k) => k !== 'include' && k !== 'exclude' && Array.isArray(matrix[k]),
);
for (const context of cartesianProduct(baseListKeys, matrix)) {
realizations.push({ runner: context[key], resolvable: true, context });
}
}
// matrix.exclude: remove matches
// matrix.exclude: remove matches by runner label (first match only).
// KNOWN LIMITATION (out of scope for #435, tracked as a follow-up): this
// matches on the runs-on key's runner label rather than the full exclude
// tuple, so a multi-axis exclude like { os: macos-latest, shell: bash } can
// remove the wrong cross-product cell. Full GitHub Actions tuple-match
// (including the include-rows-are-not-excluded rule) is deferred; #435 scopes
// only the base-list cross-product expansion above.
if (Array.isArray(matrix.exclude)) {
for (const excl of matrix.exclude) {
if (excl && excl[key] != null) {