import { readFileSync, readdirSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join, relative, extname } from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); const SCANNED = new Set(['.sh', '.ps1', '.bat', '.command', '.yml', '.yaml', '.mjs']); const SKIPPED_DIRS = new Set(['.git', 'node_modules', 'dist', '.download-cache']); function* walk(dir) { for (const entry of readdirSync(dir)) { if (SKIPPED_DIRS.has(entry)) continue; const full = join(dir, entry); if (statSync(full).isDirectory()) yield* walk(full); else yield full; } } // Node was pinned to three different versions across six scripts (v22.14.0, // v22.16.0, v22.22.1), so which runtime you got depended on how you installed. // Same class of bug as the skill-content split. NODE_VERSION is now the source // of truth; .sh/.command read it directly, .bat/.ps1 keep a literal because // reading a file there is more trouble than it is worth — this test is what // stops the literal from drifting. test('every pinned Node version matches NODE_VERSION', () => { const expected = readFileSync(join(repoRoot, 'NODE_VERSION'), 'utf8').trim(); assert.match(expected, /^v\d+\.\d+\.\d+$/, 'NODE_VERSION should look like v22.22.1'); const offenders = []; for (const file of walk(repoRoot)) { if (!SCANNED.has(extname(file))) continue; // Tests describe versions in prose; they do not pin a runtime. if (relative(repoRoot, file).startsWith('tests/')) continue; readFileSync(file, 'utf8').split(/\r?\n/).forEach((line, i) => { for (const [, found] of line.matchAll(/\bv(\d+\.\d+\.\d+)\b/g)) { // Only Node pins are in scope; ignore OpenClaw tags and release tags. if (!/node/i.test(line)) continue; if (`v${found}` !== expected) { offenders.push(`${relative(repoRoot, file)}:${i + 1} pins v${found}, expected ${expected}`); } } }); } assert.deepEqual(offenders, [], `Node version drift:\n${offenders.join('\n')}`); }); test('OPENCLAW_VERSION and NODE_VERSION ship on the drive', () => { const release = readFileSync(join(repoRoot, '.github', 'workflows', 'release.yml'), 'utf8'); // Launchers and setup scripts read these off the drive at runtime; if the // release forgets to stage them the fallback literal silently takes over. for (const name of ['OPENCLAW_VERSION', 'NODE_VERSION']) { assert.match( release, new RegExp(`cp ${name} "\\$stage_dir/${name}"`), `release.yml must copy ${name} onto the drive` ); } });