`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>
484 lines
17 KiB
JavaScript
484 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const yaml = require('js-yaml');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Policy: native shell per OS
|
|
// ---------------------------------------------------------------------------
|
|
const POLICY = Object.freeze({
|
|
'ubuntu-latest': 'bash',
|
|
'ubuntu-22.04': 'bash',
|
|
'ubuntu-24.04': 'bash',
|
|
'macos-latest': 'zsh',
|
|
'macos-13': 'zsh',
|
|
'macos-14': 'zsh',
|
|
'macos-15': 'zsh',
|
|
'windows-latest': 'pwsh',
|
|
'windows-2022': 'pwsh',
|
|
'windows-2025': 'pwsh',
|
|
});
|
|
|
|
const VIOLATION = Object.freeze({
|
|
WRONG_SHELL_FOR_OS: 'wrong_shell_for_os',
|
|
MACOS_MISSING_EXPLICIT_ZSH: 'macos_missing_explicit_zsh',
|
|
UNKNOWN_RUNNER: 'unknown_runner',
|
|
UNRESOLVABLE_MATRIX: 'unresolvable_matrix',
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runner default (GitHub Actions documented defaults, not policy)
|
|
// ---------------------------------------------------------------------------
|
|
function runnerDefault(runner) {
|
|
if (!runner) return null;
|
|
if (runner.startsWith('windows-')) return 'pwsh';
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Expand a runs-on expression against a job's strategy.matrix.
|
|
* Returns an array of { runner: string, resolvable: boolean, context: object }
|
|
* objects where `context` holds ALL key→value pairs for the realization row
|
|
* (so shell expressions like ${{ matrix.shell }} can be resolved against it).
|
|
* 'resolvable: false' means the expression was an unresolved matrix ref.
|
|
*/
|
|
function expandRunsOn(runsOnRaw, matrix) {
|
|
if (!runsOnRaw) return [];
|
|
|
|
const raw = String(runsOnRaw).trim();
|
|
|
|
// Detect matrix expression: ${{ matrix.X }} or ${{ matrix['X'] }}
|
|
const matrixExprRe = /\$\{\{\s*matrix\.(\w+)\s*\}\}/;
|
|
const match = raw.match(matrixExprRe);
|
|
|
|
if (!match) {
|
|
// Literal runner label
|
|
return [{ runner: raw, resolvable: true, context: {} }];
|
|
}
|
|
|
|
const key = match[1];
|
|
|
|
if (!matrix) {
|
|
return [{ runner: raw, resolvable: false, context: {} }];
|
|
}
|
|
|
|
const realizations = [];
|
|
|
|
// matrix.include entries carry complete row context — prefer them as they
|
|
// contain all keys (os, node-version, shell, full_only, etc.).
|
|
// Each include row is a distinct CI realization and must be validated
|
|
// independently — even if two rows share the same runner label, their
|
|
// contexts (and therefore effective shells) may differ.
|
|
if (Array.isArray(matrix.include)) {
|
|
for (const entry of matrix.include) {
|
|
if (entry && entry[key] != null) {
|
|
const runner = String(entry[key]);
|
|
// Clone all keys from include row as the realization context
|
|
const context = {};
|
|
for (const [k, v] of Object.entries(entry)) {
|
|
context[k] = v != null ? String(v) : '';
|
|
}
|
|
realizations.push({ runner, resolvable: true, context });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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])) {
|
|
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 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) {
|
|
const exclRunner = String(excl[key]);
|
|
const idx = realizations.findIndex(r => r.runner === exclRunner);
|
|
if (idx !== -1) realizations.splice(idx, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (realizations.length === 0) {
|
|
// Could not resolve — no concrete values found
|
|
return [{ runner: raw, resolvable: false, context: {} }];
|
|
}
|
|
|
|
return realizations;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Matrix expression resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* If `expr` is a `${{ matrix.<key> }}` expression, look up the value in
|
|
* `realizationContext` (a plain-object snapshot of one matrix.include row).
|
|
* Returns:
|
|
* { resolved: true, value: string } — expression resolved to a concrete value
|
|
* { resolved: false, key: string } — matrix key absent in this realization
|
|
* null — `expr` is not a matrix expression
|
|
*/
|
|
function resolveMatrixExpr(expr, realizationContext) {
|
|
if (!expr || typeof expr !== 'string') return null;
|
|
const m = expr.match(/^\s*\$\{\{\s*matrix\.(\w+)\s*\}\}\s*$/);
|
|
if (!m) return null;
|
|
const key = m[1];
|
|
if (!realizationContext || !(key in realizationContext)) {
|
|
return { resolved: false, key };
|
|
}
|
|
return { resolved: true, value: String(realizationContext[key]) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Effective-shell resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Given a step's shell, job defaults, workflow defaults, runner, and the
|
|
* current matrix realization context, return the effective shell that will
|
|
* actually execute.
|
|
*
|
|
* Matrix expressions (`${{ matrix.shell }}`) in any shell field are resolved
|
|
* against `realizationContext` (a plain object of key→value for the current
|
|
* matrix.include row).
|
|
*
|
|
* Returns:
|
|
* { shell: string, unresolvable: false } — concrete shell value
|
|
* { shell: null, unresolvable: true, key: string } — matrix expr present but key missing
|
|
*/
|
|
function effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext) {
|
|
for (const raw of [stepShell, jobDefaultsShell, workflowDefaultsShell]) {
|
|
if (!raw) continue;
|
|
const mx = resolveMatrixExpr(raw, realizationContext);
|
|
if (mx !== null) {
|
|
// It's a matrix expression
|
|
if (!mx.resolved) {
|
|
return { shell: null, unresolvable: true, key: mx.key };
|
|
}
|
|
return { shell: mx.value, unresolvable: false };
|
|
}
|
|
// Literal value
|
|
return { shell: raw, unresolvable: false };
|
|
}
|
|
// Nothing set at any level — use runner default
|
|
return { shell: runnerDefault(runner), unresolvable: false };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Violation detection
|
|
// ---------------------------------------------------------------------------
|
|
/**
|
|
* Determines whether a step/runner combination violates shell policy.
|
|
*
|
|
* `rawStepShell`, `rawJobDefaultsShell`, `rawWorkflowDefaultsShell` are the
|
|
* raw (possibly matrix-expression) values before resolution. They're used
|
|
* only for the MACOS_MISSING_EXPLICIT_ZSH sub-classification: that violation
|
|
* fires only when nothing is set at any level (all three are null/empty AND
|
|
* the runner default is wrong).
|
|
*/
|
|
function detectViolation(runner, resolvedShell, rawStepShell, rawJobDefaultsShell, rawWorkflowDefaultsShell) {
|
|
if (!(runner in POLICY)) {
|
|
return VIOLATION.UNKNOWN_RUNNER;
|
|
}
|
|
const expected = POLICY[runner];
|
|
// GHA accepts custom shells as a format string containing '{0}' (e.g. 'zsh {0}').
|
|
// Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
|
// the shell name before the space is the executable; strip the format suffix before
|
|
// comparing against the policy so 'zsh {0}' satisfies the 'zsh' requirement.
|
|
const normalizedShell = resolvedShell ? resolvedShell.replace(/\s+\{0\}$/, '') : resolvedShell;
|
|
if (normalizedShell !== expected) {
|
|
// Specific subtype for macOS missing explicit zsh:
|
|
// fires only when no shell is set at any level (inherited runner default).
|
|
if (runner.startsWith('macos-') && !rawStepShell && !rawJobDefaultsShell && !rawWorkflowDefaultsShell) {
|
|
return VIOLATION.MACOS_MISSING_EXPLICIT_ZSH;
|
|
}
|
|
return VIOLATION.WRONG_SHELL_FOR_OS;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source map: find line numbers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Find the line number of a string in YAML text.
|
|
* Returns 1-based line number of the first occurrence at or after startLine.
|
|
*/
|
|
function findLineNumber(yamlText, searchStr, startLine) {
|
|
const lines = yamlText.split('\n');
|
|
const start = Math.max(0, (startLine || 1) - 1);
|
|
for (let i = start; i < lines.length; i++) {
|
|
if (lines[i].includes(searchStr)) {
|
|
return i + 1;
|
|
}
|
|
}
|
|
// Fall back to scanning from beginning
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].includes(searchStr)) {
|
|
return i + 1;
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core inspector
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* inspectWorkflow(yamlText, { filePath }) → structured inspection result
|
|
*/
|
|
function inspectWorkflow(yamlText, { filePath = '<unknown>' } = {}) {
|
|
let doc;
|
|
try {
|
|
doc = yaml.load(yamlText, { schema: yaml.DEFAULT_SCHEMA });
|
|
} catch (e) {
|
|
return {
|
|
filePath,
|
|
jobs: [],
|
|
workflowDefaultsShell: null,
|
|
parseError: e.message,
|
|
};
|
|
}
|
|
|
|
if (!doc || typeof doc !== 'object') {
|
|
return { filePath, jobs: [], workflowDefaultsShell: null };
|
|
}
|
|
|
|
const workflowDefaultsShell =
|
|
doc.defaults?.run?.shell ?? null;
|
|
|
|
const jobs = [];
|
|
|
|
for (const [jobId, jobDef] of Object.entries(doc.jobs || {})) {
|
|
if (!jobDef || typeof jobDef !== 'object') continue;
|
|
|
|
const runsOnRaw = jobDef['runs-on'];
|
|
const matrix = jobDef.strategy?.matrix ?? null;
|
|
const jobDefaultsShell = jobDef.defaults?.run?.shell ?? null;
|
|
|
|
const runsOnStr = runsOnRaw != null ? String(runsOnRaw) : '';
|
|
const runsOnExpressions = [runsOnStr];
|
|
const runnerRealizations = expandRunsOn(runsOnStr, matrix);
|
|
|
|
const steps = [];
|
|
|
|
for (const [stepIndex, step] of (jobDef.steps || []).entries()) {
|
|
if (!step || typeof step !== 'object') continue;
|
|
|
|
// Only check steps that actually run shell scripts (have `run:`)
|
|
if (!step.run) continue;
|
|
|
|
const stepShell = step.shell ?? null;
|
|
const stepName = step.name ?? `step-${stepIndex}`;
|
|
|
|
for (const { runner, resolvable, context: realizationContext } of runnerRealizations) {
|
|
if (!resolvable) {
|
|
// Can't resolve runner — emit UNRESOLVABLE_MATRIX
|
|
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
|
|
steps.push({
|
|
index: stepIndex,
|
|
name: stepName,
|
|
stepShell,
|
|
effectiveShell: null,
|
|
runner,
|
|
violation: VIOLATION.UNRESOLVABLE_MATRIX,
|
|
evidence: {
|
|
line: lineNum,
|
|
snippet: `runs-on: ${runsOnStr} (unresolvable matrix expression)`,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const effResult = effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext);
|
|
|
|
// If a matrix expression referenced a key not present in this realization row
|
|
if (effResult.unresolvable) {
|
|
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
|
|
steps.push({
|
|
index: stepIndex,
|
|
name: stepName,
|
|
stepShell,
|
|
effectiveShell: null,
|
|
runner,
|
|
violation: VIOLATION.UNRESOLVABLE_MATRIX,
|
|
evidence: {
|
|
line: lineNum,
|
|
snippet: `matrix.${effResult.key} not present in realization for runner=${runner}`,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const eff = effResult.shell;
|
|
const violation = detectViolation(runner, eff, stepShell, jobDefaultsShell, workflowDefaultsShell);
|
|
|
|
// Find evidence line: prefer step name, then shell:, then run: content
|
|
let evidenceLine = 1;
|
|
let evidenceSnippet = '';
|
|
|
|
if (stepName !== `step-${stepIndex}`) {
|
|
evidenceLine = findLineNumber(yamlText, stepName);
|
|
evidenceSnippet = `name: ${stepName}`;
|
|
} else if (stepShell) {
|
|
evidenceLine = findLineNumber(yamlText, `shell: ${stepShell}`);
|
|
evidenceSnippet = `shell: ${stepShell}`;
|
|
} else {
|
|
const runSnippet = String(step.run).split('\n')[0].slice(0, 40);
|
|
evidenceLine = findLineNumber(yamlText, runSnippet);
|
|
evidenceSnippet = runSnippet;
|
|
}
|
|
|
|
steps.push({
|
|
index: stepIndex,
|
|
name: stepName,
|
|
stepShell,
|
|
effectiveShell: eff,
|
|
runner,
|
|
violation: violation ?? null,
|
|
evidence: {
|
|
line: evidenceLine,
|
|
snippet: evidenceSnippet,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const resolvedRunners = runnerRealizations
|
|
.filter(r => r.resolvable)
|
|
.map(r => r.runner);
|
|
|
|
jobs.push({
|
|
jobId,
|
|
runsOnExpressions,
|
|
resolvedRunners,
|
|
defaultsShell: jobDefaultsShell,
|
|
steps,
|
|
});
|
|
}
|
|
|
|
return {
|
|
filePath,
|
|
jobs,
|
|
workflowDefaultsShell,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* inspectWorkflowFile(absPath) — reads file from disk and calls inspectWorkflow.
|
|
*/
|
|
function inspectWorkflowFile(absPath) {
|
|
const text = fs.readFileSync(absPath, 'utf8');
|
|
return inspectWorkflow(text, { filePath: absPath });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// runPolicyLint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* runPolicyLint({ workflowsDir }) → { violations, summary }
|
|
*/
|
|
function runPolicyLint({ workflowsDir }) {
|
|
const absDir = path.resolve(workflowsDir);
|
|
const files = fs.readdirSync(absDir)
|
|
.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
.map(f => path.join(absDir, f))
|
|
.sort();
|
|
|
|
const violations = [];
|
|
|
|
for (const filePath of files) {
|
|
const result = inspectWorkflowFile(filePath);
|
|
for (const job of result.jobs) {
|
|
for (const step of job.steps) {
|
|
if (step.violation) {
|
|
violations.push({
|
|
filePath: result.filePath,
|
|
jobId: job.jobId,
|
|
stepIndex: step.index,
|
|
stepName: step.name,
|
|
runner: step.runner,
|
|
effectiveShell: step.effectiveShell,
|
|
stepShell: step.stepShell,
|
|
violation: step.violation,
|
|
evidence: step.evidence,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const perViolationType = {};
|
|
for (const v of violations) {
|
|
perViolationType[v.violation] = (perViolationType[v.violation] || 0) + 1;
|
|
}
|
|
|
|
return {
|
|
violations,
|
|
summary: {
|
|
total: violations.length,
|
|
perViolationType,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exports
|
|
// ---------------------------------------------------------------------------
|
|
module.exports = {
|
|
POLICY,
|
|
VIOLATION,
|
|
inspectWorkflow,
|
|
inspectWorkflowFile,
|
|
runPolicyLint,
|
|
};
|