igneum/tools/ci/check-workflow-shell.mjs
igneum-labs 4b6706d1dc Public-testnet readiness: fee floors and pgas table analysis, testnet identity doc, G14 rewrite plan with dry run, G13 signed build inputs, testnet terms, MetaMask page
- docs/analysis/base-fee-floor.md: the base-fee floors (100 gwei per gas, 10,000 gwei per pgas), B_p 120,000 and
  S_p 30,000 pgas, the calibrated v1 pgas table (intrinsic 300, modexp 10 + 1 per 10 bytes) from the measured 44
  cycles per EVM gas and 9 cycles per pgas, with the arithmetic and a stated price assumption; spec 05 section 5.10.
  The parameters are implemented on the node fork branch testnet-params (vendor, not in this repository).
- docs/testnet/README.md: igneum-testnet-1 (chain id 4462, ports 268xx, frozen genesis 2026-10-05T00:00:00Z with
  its message and hash, mainnet finality window, every switch from genesis, no override file) and the reset policy.
  Every value proposed, for the morning sign-off.
- docs/plans/history-rewrite.md: G14, the exact git-filter-repo pass, the dry run on a throwaway mirror clone (0
  identity hits, 0 secrets, every stamp +0000, 312 commits), what breaks and the order for the morning.
- G13: app/igneum-app/src/inputs.rs and igneum-ota-sign sign-inputs / verify-inputs; push-inputs.sh signs
  payload-inputs.json with the OTA key and pins the node commit (packaging/windows/node-source.pin);
  windows.yml verifies the signature with the embedded key, the zip, every file and the pin before building and
  uploads the verified record; fetch-ci-artifacts.sh signs the update manifest only with --sign-manifest <run-id>
  after re-verifying that run's inputs. test-inputs-signing.sh (16 cases) and tools/ci/check-workflow-shell.mjs.
- site: testnet terms on the download section, wallet.html (wallet_addEthereumChain, chain ids 4462/4463, IGN, 18
  decimals), the litepaper's app paragraph (MetaMask and the coming Igneum Wallet, no hardware wallet) and the
  miner fee sentence (no protocol fee; optional 1% in the miner software, off with one flag). node site/build.mjs
  and the link check pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 22:54:56 +00:00

101 lines
6.6 KiB
JavaScript

// 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);