// Mac-side (and CI) parse check of the shell inside .github/workflows/*.yml, so a broken `run:` block is caught before // a Windows runner spends twenty minutes on it (4 October 2026, the signed-inputs step of windows.yml). // // node tools/ci/check-workflow-shell.mjs [workflow.yml ...] default: every workflow under .github/workflows // // For every step with a `run: |` block: `shell: bash` (or no shell on an ubuntu job) goes through `bash -n`; // `shell: powershell` and `shell: pwsh` blocks, and every .ps1 the Windows folders hold, are checked against the one // rule Windows PowerShell 5.1 enforces that newer parsers may not: a drive-qualified variable reference "$name: text" // inside a double-quoted string (tools/ci/windows/check-ps51.ps1 runs the real 5.1 parser on the runner; this is the // Mac approximation of its rule, with the same negative fixture). `shell: cmd` blocks are checked for the bare ")" // class only when they span more than one line. Exit 1 on any finding, with file:line. import { readFileSync, readdirSync, writeFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const repo = join(here, '..', '..'); const wfDir = join(repo, '.github', 'workflows'); const files = process.argv.length > 2 ? process.argv.slice(2) : readdirSync(wfDir).filter(f => /\.ya?ml$/.test(f)).map(f => join(wfDir, f)); const tmp = mkdtempSync(join(tmpdir(), 'wf-shell-')); let findings = 0, blocks = 0, ps1 = 0; const say = (file, line, msg) => { findings++; console.log(`${file}:${line}: ${msg}`); }; // The 5.1 rule: inside a double-quoted string, `$identifier:` is read as a drive-qualified variable reference // (`$env:PATH`, `$script:node`), so when the character after the colon cannot start a variable name (a space, a // `$`, punctuation or the closing quote) 5.1 fails with "Variable reference is not valid. ':' was not followed by a // valid variable name character". `$env:PATH`, `$script:x`, `${name}:` and `$($name):` are fine. const DRIVE_REF = /"(?:[^"\\]|\\.|`")*?\$[A-Za-z_][A-Za-z0-9_]*:(?![A-Za-z0-9_])(?:[^"\\]|\\.|`")*"/; function checkPowerShell(text, file, firstLine) { const lines = text.split('\n'); lines.forEach((l, i) => { const noComment = l.replace(/^\s*#.*$/, ''); if (DRIVE_REF.test(noComment) && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}:/.test(noComment)) say(file, firstLine + i, `PowerShell 5.1 rejects "$name: text" (drive-qualified variable reference): ${l.trim().slice(0, 100)}`); }); } // the same negative fixture check-ps51.ps1 uses: the Mac rule must bite on it or it proves nothing const fixture = join(repo, 'tools', 'ci', 'windows', 'fixtures', 'bad-drive-ref.ps1.txt'); if (existsSync(fixture)) { const before = findings; checkPowerShell(readFileSync(fixture, 'utf8'), 'fixture', 1); if (findings === before) { console.log('self-test failed: the Mac rule does not fire on tools/ci/windows/fixtures/bad-drive-ref.ps1.txt'); process.exit(2); } findings = before; console.log('self-test: the 5.1 drive-reference rule fires on the fixture'); } function checkBash(text, file, firstLine) { const p = join(tmp, `block-${blocks}.sh`); writeFileSync(p, text); const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); if (r.status !== 0) say(file, firstLine, `bash -n: ${(r.stderr || '').trim().replace(p, 'block').split('\n')[0]}`); } function checkCmd(text, file, firstLine) { text.split('\n').forEach((l, i) => { if (/^\s*\)\s*$/.test(l)) say(file, firstLine + i, `a bare ")" line in a cmd block (the 3 October class)`); }); } for (const file of files) { const rel = file.startsWith(repo) ? file.slice(repo.length + 1) : file; const lines = readFileSync(file, 'utf8').split('\n'); let runsOn = ''; for (let i = 0; i < lines.length; i++) { const m = /^(\s*)runs-on:\s*(\S+)/.exec(lines[i]); if (m) runsOn = m[2]; const r = /^(\s*)run:\s*\|\s*$/.exec(lines[i]); if (!r) continue; const indent = r[1].length; // the step's shell: look back to the step's "- name:" for a `shell:` key at the same indent as `run:` let shell = ''; for (let k = i - 1; k >= 0; k--) { const s = /^(\s*)shell:\s*(\S+)/.exec(lines[k]); if (s && s[1].length === indent) { shell = s[2]; break; } if (/^\s*-\s+(name|uses|run):/.test(lines[k]) && /^\s*-/.test(lines[k]) && lines[k].search(/\S/) < indent) break; } // the block: every following line indented deeper than `run:` const body = []; let j = i + 1; while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > indent)) { body.push(lines[j]); j++; } while (body.length && body[body.length - 1].trim() === '') body.pop(); const bodyIndent = Math.min(...body.filter(l => l.trim()).map(l => l.search(/\S/))); const text = body.map(l => l.slice(bodyIndent)).join('\n') + '\n'; const firstLine = i + 2; blocks++; const kind = shell || (runsOn.startsWith('windows') ? 'pwsh' : 'bash'); if (kind === 'bash') checkBash(text, rel, firstLine); else if (kind === 'powershell' || kind === 'pwsh') checkPowerShell(text, rel, firstLine); else if (kind === 'cmd') checkCmd(text, rel, firstLine); i = j - 1; } } // every .ps1 the Windows folders hold, the same rule const folders = ['proto-cuda/windows-app', 'proto-cuda/windows-miner', 'proto-cuda/windows-node', 'proving/windows-wsl2', 'relay/clients', 'relay/playbooks', 'packaging/windows', 'app/windows', 'tools/ci/windows']; function walk(d) { if (!existsSync(d)) return []; return readdirSync(d).flatMap(f => { const p = join(d, f); return statSync(p).isDirectory() ? walk(p) : (f.endsWith('.ps1') ? [p] : []); }); } for (const f of folders) for (const p of walk(join(repo, f))) { ps1++; checkPowerShell(readFileSync(p, 'utf8'), p.slice(repo.length + 1), 1); } // the shell scripts the workflow and the Mac side run for (const f of ['packaging/windows/push-inputs.sh', 'packaging/windows/fetch-ci-artifacts.sh', 'packaging/windows/inputs-manifest.sh', 'packaging/windows/test-inputs-signing.sh', 'packaging/ota/publish-manifest.sh', 'packaging/windows/make-payload.sh']) { const p = join(repo, f); if (!existsSync(p)) continue; const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); if (r.status !== 0) say(f, 1, `bash -n: ${(r.stderr || '').trim().split('\n')[0]}`); } rmSync(tmp, { recursive: true, force: true }); console.log(`workflow shell: ${blocks} run blocks in ${files.length} workflow(s), ${ps1} .ps1 files, ${findings} finding(s)`); process.exit(findings ? 1 : 0);