Some checks failed
Tests / PR mergeability (push) Successful in 1m37s
Tests / Base branch health (push) Successful in 11s
Tests / Detect test scope (push) Successful in 17s
Tests / lint-tests (push) Failing after 2m16s
Tests / plugin-validate (push) Successful in 1m6s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 25s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 19s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 8s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
The fork lives on the golem15 Gitea forge, not GitHub. Package identity now parses either host and derives in-place raw URLs for Gitea; the identity-drift lint accepts the new host; README drops GitHub-only badges and the npm quickstart in favour of the checkout installer.
418 lines
16 KiB
JavaScript
418 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
process.env.MSD_TEST_MODE = '1';
|
|
|
|
/**
|
|
* Asserts that every `run:` step whose command begins with `npm ` in any
|
|
* .github/workflows/*.yml file has an effective `shell:` directive that is
|
|
* H1-policy-compliant (native shell per OS).
|
|
*
|
|
* H1 policy (LOCKED — golem15/msd-core):
|
|
* ubuntu-* → bash (runner default, no pin needed)
|
|
* macos-* → zsh (must be pinned explicitly)
|
|
* windows-* → pwsh (runner default, no pin needed)
|
|
*
|
|
* "Effective shell" is resolved as:
|
|
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell ?? runner_default
|
|
*
|
|
* Under H1, Windows runner default is pwsh. pwsh does NOT have the npm.cmd
|
|
* stderr-swallow issue that prompted the original bash requirement — that issue
|
|
* was specific to running bash-wrapped npm in a pwsh session. With H1 in force,
|
|
* Windows npm steps run natively under pwsh and are reliable.
|
|
*
|
|
* Violation conditions (H1-aware):
|
|
* - An npm run: step on a Windows runner has shell: bash (wrong shell for OS,
|
|
* and reintroduces the pwsh/bash interop issue H1 is designed to eliminate).
|
|
* - An npm run: step on a macOS runner has no effective shell (macos default
|
|
* is bash, but H1 requires zsh — tracked by policy-shell-pinning.test.cjs).
|
|
*
|
|
* This test enforces the Windows side: Windows npm steps MUST NOT use shell: bash
|
|
* (either directly or via job/workflow defaults). No shell pin = pwsh default = correct.
|
|
*
|
|
* Scope: only workflow files that reference a Windows hosted runner label.
|
|
* Acceptable outcomes: no shell pin (pwsh default), or explicit shell: pwsh.
|
|
* Unacceptable: shell: bash (H1 violation on Windows).
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows');
|
|
|
|
/**
|
|
* Collect all .yml / .yaml files under the workflows directory.
|
|
*
|
|
* Only files that reference a Windows hosted label (`windows-latest` or
|
|
* `windows-2025`, as a literal runs-on value or inside a matrix.os list) are
|
|
* class can only manifest in workflows that target Windows runners.
|
|
*/
|
|
function listWorkflowFiles() {
|
|
const entries = fs.readdirSync(WORKFLOWS_DIR);
|
|
const all = entries
|
|
.filter((e) => /\.ya?ml$/.test(e))
|
|
.map((e) => path.join(WORKFLOWS_DIR, e));
|
|
|
|
// Filter to files that have at least one Windows hosted-runner reference.
|
|
// allow-test-rule: source-text-is-the-product
|
|
// Reads the shipped .github/workflows/*.yml — the deployed text GitHub
|
|
// Actions executes — to detect whether a workflow targets Windows runners
|
|
// at all. The pwsh
|
|
// stderr-swallow class is windows-only, so files that never mention
|
|
// windows-hosted labels are out of scope. Exposing a typed IR from production
|
|
// code is not appropriate here because the source-of-truth is the YAML
|
|
// itself; the actual test assertions below ARE structural (parse runs-on,
|
|
// strategy.matrix.os, defaults.run.shell, etc.).
|
|
return all.filter((f) => {
|
|
const raw = fs.readFileSync(f, 'utf8');
|
|
return raw.includes('windows-latest') || raw.includes('windows-2025');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Return the number of leading spaces in a line.
|
|
*/
|
|
function indentOf(line) {
|
|
const m = line.match(/^(\s*)/);
|
|
return m ? m[1].length : 0;
|
|
}
|
|
|
|
/**
|
|
* Parse a workflow YAML file with a line-based scanner.
|
|
*
|
|
* Returns an array of violation objects:
|
|
* { file, job, stepIndex, stepName, runLine }
|
|
*
|
|
* A violation is a `run:` step whose command starts with `npm ` and that
|
|
* does NOT have an effective `shell:` directive. Effective shell is:
|
|
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell
|
|
*
|
|
* Strategy:
|
|
* 1. Walk lines top-to-bottom tracking workflow-level defaults.run.shell.
|
|
* 2. Track job keys (jobs.<key>) and their defaults.run.shell.
|
|
* 3. Detect step boundaries: a line matching /^\s+-\s+(name:|uses:|run:)/ at
|
|
* "step list" indentation (8 spaces for most workflows, detected
|
|
* dynamically) opens a new step context.
|
|
* 4. Within a step context, collect all keys (name, run, shell, uses, …).
|
|
* 5. At the END of each step context (next step boundary or end-of-job),
|
|
* emit a violation if `run` starts with `npm ` and effective shell is null.
|
|
*/
|
|
function findViolations(filePath) {
|
|
const relFile = path.relative(REPO_ROOT, filePath);
|
|
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
const violations = [];
|
|
|
|
// Workflow-level defaults.run.shell
|
|
let workflowDefaultShell = null;
|
|
|
|
// ── State machine ─────────────────────────────────────────────────────────
|
|
let inJobs = false;
|
|
let currentJob = null;
|
|
let jobDefaultShell = null; // job-level defaults.run.shell
|
|
|
|
// Section tracking for defaults blocks
|
|
// We need to detect:
|
|
// defaults: (at col 0 = workflow level, or col 2 = job level)
|
|
// run:
|
|
// shell: bash
|
|
let inDefaultsBlock = false; // currently inside a `defaults:` mapping
|
|
let inDefaultsRunBlock = false; // currently inside `defaults: run:`
|
|
let defaultsBlockOwner = null; // 'workflow' or 'job'
|
|
let _defaultsBlockCol = null; // column of the `defaults:` key
|
|
|
|
// Strategy/matrix tracking
|
|
let _inStrategyBlock = false;
|
|
let _inMatrixBlock = false;
|
|
let _inMatrixOsBlock = false;
|
|
let _strategyCol = null;
|
|
let _matrixCol = null;
|
|
|
|
// Step tracking
|
|
let stepIndent = null; // indent level of the `- name:/run:/uses:` items
|
|
let inStep = false;
|
|
let stepIndex = -1;
|
|
let stepProps = null; // { name, run, shell }
|
|
|
|
/**
|
|
* Flush the current step: emit a violation if it qualifies.
|
|
*
|
|
* H1-aware check: Windows npm steps must NOT use shell: bash.
|
|
* Under H1, Windows runner default is pwsh (native, reliable for npm).
|
|
* Using shell: bash on Windows is an H1 policy violation AND reintroduces
|
|
* the pwsh/bash interop issue the original rule was designed to prevent.
|
|
*
|
|
* Effective shell = step.shell ?? jobDefaultShell ?? workflowDefaultShell
|
|
* (null means runner default applies — pwsh for windows, which is correct)
|
|
*/
|
|
function flushStep() {
|
|
if (!inStep || stepProps === null) return;
|
|
const { name, run, shell } = stepProps;
|
|
const effectiveShell = shell !== null ? shell
|
|
: jobDefaultShell !== null ? jobDefaultShell
|
|
: workflowDefaultShell;
|
|
// H1 violation: npm step on Windows with shell: bash (explicit or via defaults)
|
|
if (run !== null && /^\s*(?:npm|npx)(\s|$)/.test(run) && effectiveShell === 'bash') {
|
|
violations.push({
|
|
file: relFile,
|
|
job: currentJob,
|
|
stepIndex,
|
|
stepName: name || '(unnamed)',
|
|
effectiveShell,
|
|
});
|
|
}
|
|
inStep = false;
|
|
stepProps = null;
|
|
}
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trimStart();
|
|
const col = indentOf(line);
|
|
|
|
// Skip blank lines and comments
|
|
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
|
|
// ── Workflow-level `defaults:` block (col 0) ──────────────────────────
|
|
// Detect `defaults:` at the root level (before `jobs:`)
|
|
if (!inJobs && /^defaults\s*:/.test(line)) {
|
|
inDefaultsBlock = true;
|
|
inDefaultsRunBlock = false;
|
|
defaultsBlockOwner = 'workflow';
|
|
_defaultsBlockCol = 0;
|
|
continue;
|
|
}
|
|
|
|
if (inDefaultsBlock && defaultsBlockOwner === 'workflow') {
|
|
// A key at col 0 that isn't blank/comment ends the defaults block
|
|
if (col === 0 && !/^\s/.test(line)) {
|
|
inDefaultsBlock = false;
|
|
inDefaultsRunBlock = false;
|
|
} else if (/^\s+run\s*:/.test(line) && col === 2) {
|
|
inDefaultsRunBlock = true;
|
|
} else if (inDefaultsRunBlock && /^\s+shell\s*:\s*(\S+)/.test(line)) {
|
|
const m = line.match(/^\s+shell\s*:\s*(\S+)/);
|
|
if (m) workflowDefaultShell = m[1];
|
|
}
|
|
}
|
|
|
|
// ── `jobs:` section ───────────────────────────────────────────────────
|
|
if (/^jobs\s*:/.test(line)) {
|
|
inJobs = true;
|
|
inDefaultsBlock = false;
|
|
inDefaultsRunBlock = false;
|
|
continue;
|
|
}
|
|
|
|
if (!inJobs) continue;
|
|
|
|
// ── Job-level keys at indent 2 ────────────────────────────────────────
|
|
if (col === 2 && /^[a-zA-Z0-9_-]+\s*:/.test(trimmed)) {
|
|
flushStep();
|
|
currentJob = trimmed.replace(/\s*:.*/, '');
|
|
stepIndent = null;
|
|
inStep = false;
|
|
stepIndex = -1;
|
|
jobDefaultShell = null;
|
|
// Reset sub-section tracking
|
|
inDefaultsBlock = false;
|
|
inDefaultsRunBlock = false;
|
|
_inStrategyBlock = false;
|
|
_inMatrixBlock = false;
|
|
_inMatrixOsBlock = false;
|
|
continue;
|
|
}
|
|
|
|
if (currentJob === null) continue;
|
|
|
|
// ── Job-level `defaults:` block (col 4) ──────────────────────────────
|
|
if (col === 4 && /^defaults\s*:/.test(trimmed)) {
|
|
inDefaultsBlock = true;
|
|
inDefaultsRunBlock = false;
|
|
defaultsBlockOwner = 'job';
|
|
_defaultsBlockCol = 4;
|
|
continue;
|
|
}
|
|
|
|
if (inDefaultsBlock && defaultsBlockOwner === 'job') {
|
|
if (col <= 4 && !/^\s{5}/.test(line)) {
|
|
// Back to job level or above — end defaults block
|
|
inDefaultsBlock = false;
|
|
inDefaultsRunBlock = false;
|
|
} else if (col === 6 && /^run\s*:/.test(trimmed)) {
|
|
inDefaultsRunBlock = true;
|
|
} else if (inDefaultsRunBlock && col === 8 && /^shell\s*:\s*(\S+)/.test(trimmed)) {
|
|
const m = trimmed.match(/^shell\s*:\s*(\S+)/);
|
|
if (m) jobDefaultShell = m[1];
|
|
}
|
|
}
|
|
|
|
// ── Step list detection ───────────────────────────────────────────────
|
|
const stepStartMatch = line.match(
|
|
/^(\s+)-\s+(name|run|uses|shell|if|id|env|with|continue-on-error|timeout-minutes|working-directory)\s*[:|]/,
|
|
);
|
|
if (stepStartMatch) {
|
|
const thisIndent = stepStartMatch[1].length;
|
|
|
|
if (stepIndent === null) {
|
|
stepIndent = thisIndent;
|
|
}
|
|
|
|
if (thisIndent === stepIndent) {
|
|
// New step boundary
|
|
flushStep();
|
|
stepIndex += 1;
|
|
inStep = true;
|
|
stepProps = { name: null, run: null, shell: null };
|
|
// Reset defaults sub-tracking when we enter the steps section
|
|
inDefaultsBlock = false;
|
|
inDefaultsRunBlock = false;
|
|
|
|
// Parse the key on this same line
|
|
const keyMatch = line.match(/^\s+-\s+(name|run|shell|uses)\s*:\s*(.*)/);
|
|
if (keyMatch) {
|
|
const key = keyMatch[1];
|
|
const val = keyMatch[2].trim();
|
|
if (key === 'name') stepProps.name = val || null;
|
|
else if (key === 'run') stepProps.run = val || null;
|
|
else if (key === 'shell') stepProps.shell = val || null;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// ── Inside a step: parse continuation key-value pairs ─────────────────
|
|
if (inStep && stepIndent !== null && col > stepIndent) {
|
|
const kvMatch = line.match(/^\s+(name|run|shell|uses)\s*:\s*(.*)/);
|
|
if (kvMatch) {
|
|
const key = kvMatch[1];
|
|
const val = kvMatch[2].trim();
|
|
if (key === 'run') {
|
|
if (val && val !== '|') {
|
|
stepProps.run = val;
|
|
} else {
|
|
// Multi-line run block — find first non-empty continuation line
|
|
let j = i + 1;
|
|
while (j < lines.length) {
|
|
const contLine = lines[j];
|
|
const contTrimmed = contLine.trimStart();
|
|
if (contTrimmed === '' || contTrimmed.startsWith('#')) { j++; continue; }
|
|
if (indentOf(contLine) <= col) break;
|
|
stepProps.run = contTrimmed;
|
|
break;
|
|
}
|
|
}
|
|
} else if (key === 'shell') {
|
|
stepProps.shell = val || null;
|
|
} else if (key === 'name') {
|
|
stepProps.name = val || null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush the last step
|
|
flushStep();
|
|
|
|
return violations;
|
|
}
|
|
|
|
// ── Unit helper: scanner exercised against a synthetic YAML string ──────────
|
|
|
|
/**
|
|
* Parse violation list from a raw YAML string (written to a temp file).
|
|
* Used by the defaults.run.shell unit test below.
|
|
*/
|
|
function findViolationsInString(yamlContent) {
|
|
const tmpPath = path.join(require('os').tmpdir(), `msd-shell-test-${process.pid}.yml`);
|
|
fs.writeFileSync(tmpPath, yamlContent, 'utf8');
|
|
try {
|
|
return findViolations(tmpPath);
|
|
} finally {
|
|
fs.unlinkSync(tmpPath);
|
|
}
|
|
}
|
|
|
|
// ── Test suite ──────────────────────────────────────────────────────────────
|
|
|
|
describe('GitHub Actions workflow shell pinning', () => {
|
|
test('npm ci/run steps in Windows-targeting workflow files must not use shell: bash (H1 policy)', () => {
|
|
const workflowFiles = listWorkflowFiles();
|
|
assert.ok(workflowFiles.length > 0, 'No windows-targeting workflow files found — check WORKFLOWS_DIR path');
|
|
|
|
const allViolations = [];
|
|
for (const wf of workflowFiles) {
|
|
const v = findViolations(wf);
|
|
allViolations.push(...v);
|
|
}
|
|
|
|
if (allViolations.length > 0) {
|
|
const details = allViolations.map(
|
|
(v) => ` jobs.${v.job}.steps[${v.stepIndex}].name = ${v.stepName} shell=${v.effectiveShell} (${v.file})`,
|
|
).join('\n');
|
|
assert.fail(
|
|
`${allViolations.length} npm run/ci step(s) use shell: bash in a Windows-targeting workflow file.\n` +
|
|
`H1 policy: Windows runners must use pwsh (runner default — no explicit pin needed).\n` +
|
|
`shell: bash on Windows is both an H1 violation and reintroduces pwsh/bash interop issues.\n` +
|
|
`Remove the shell: bash directive (or change to shell: pwsh) on each listed step:\n\n` +
|
|
details,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('workflow-level defaults.run.shell: bash on Windows-targeting workflow is an H1 violation', () => {
|
|
// H1: Windows runners must use pwsh (runner default). Setting defaults.run.shell: bash
|
|
// at the workflow level forces npm steps on Windows to use bash — an H1 violation.
|
|
const yaml = `
|
|
name: Test
|
|
on: push
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
jobs:
|
|
build:
|
|
runs-on: windows-latest
|
|
steps:
|
|
- name: Install
|
|
run: npm ci
|
|
- name: Build
|
|
run: npm run build
|
|
`.trimStart();
|
|
const violations = findViolationsInString(yaml);
|
|
assert.strictEqual(
|
|
violations.length,
|
|
2,
|
|
`Expected 2 violations (both npm steps inherit shell: bash via workflow defaults — H1 violation), got:\n` +
|
|
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName} shell=${v.effectiveShell}`).join('\n'),
|
|
);
|
|
});
|
|
|
|
test('job-level defaults.run.shell: bash on Windows job is an H1 violation', () => {
|
|
// H1: Windows runners must use pwsh. job-level defaults.run.shell: bash
|
|
// forces Windows npm steps to use bash — an H1 violation.
|
|
const yaml = `
|
|
name: Test
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: windows-latest
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
steps:
|
|
- name: Install
|
|
run: npm ci
|
|
- name: Build
|
|
run: npm run build
|
|
`.trimStart();
|
|
const violations = findViolationsInString(yaml);
|
|
assert.strictEqual(
|
|
violations.length,
|
|
2,
|
|
`Expected 2 violations (both npm steps inherit shell: bash via job defaults — H1 violation), got:\n` +
|
|
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName} shell=${v.effectiveShell}`).join('\n'),
|
|
);
|
|
});
|
|
});
|