openvibers/openvibe.shared Commits

Pin bot: raise budgets itself and ship the round (#431)

OP OpenVibers committed 3 days ago · 31fc6f388d · 2 files, +390 −10

* Pin bot: raise budgets itself and ship the round (plan ask 307)

After moving a repository's pins, the bot measures its size-budget
test and raises only the keys the bump outgrew to the measured value
(next 0.5) with a dated note naming the release, so the round opens
green PRs instead of twenty-five red ones; anything unprovable (no
budget test, a failed install, a failure without an 'over budget'
line, a forbidden file on Live's home page) is left for the driver.
--ship then waits for each new PR's checks and merges-and-deploys it
through merge-deploy.sh with the service from one table (71
repositories; Live and Media last, never --force), one at a time.
Dry-run by default.

* Pin bot test: expect path.join, not POSIX separators

shipCommand joins the tools dir with path.join, which is backslashes
on Windows; the test assumed forward slashes and failed the Windows
leg.

Changes

ddiff --git a/scripts/pin-bump.js b/scripts/pin-bump.js
iindex ab452f1..b91c991 100644
---- a/scripts/pin-bump.js
++++ b/scripts/pin-bump.js
@@@ -7,14 +7,20 @@
* version the repository's STATUS.json and README.md name for the library, and moves the repository's
* security.yml@<sha> pin of the reusable security workflow to the newest OpenVibe.Shared commit that changed
* .github/workflows (also opened alone, on a ci-shared-workflows-<sha> branch, when only that pin is stale).
-- * Nothing is tested here: each PR's CI runs
-- * the full suite and pin drift (scripts/pin-drift.js), and the PR is merged only when it is green.
++ * After the bump, a repository with a size-budget test (test/perf-budget.test.js, Tools' gateway budget,
++ * Live's scripts/perf/check-budgets.js) gets its budgets raised to the new measurement when the bump
++ * outgrows them, so the round opens green PRs instead of twenty-five red ones; anything the budget run
++ * cannot prove (no budget test, a failed install, a failure without an "over budget" line, a forbidden
++ * file on Live's home page) is left for the round driver, exactly as before.
*
-- * node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply] [--workdir DIR]
++ * node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply] [--ship] [--workdir DIR]
* library openvibe-contracts | openvibe-shared | openvibe-sdk | openvibe-publishing
* tag the published release, e.g. v0.112.0 (must exist in the library's repository)
* --repos only these repositories (names under OpenVibers/), default every repository of the organisation
* --apply push the branches and open the PRs (default: a dry run that prints what each PR would change)
++ * --ship after opening, wait for each new PR's checks and merge-and-deploy it (SERVICE below;
++ * Live and Media last, never --force), one at a time. Dry-run by default: without
++ * --apply it prints the ship plan instead of running it.
* --workdir where the shallow clones go (default a fresh directory under the OS temp dir, removed afterwards)
*
* Needs git, npm, pnpm (for pnpm repositories) and an authenticated `gh` (it lists the organisation's repositories
@@@ -41,9 +47,10 @@ function run(cmd, args, opts = {}) {
}
function parseArgs(argv) {
-- const out = { library: argv[0], tag: argv[1], repos: null, apply: false, workdir: null };
++ const out = { library: argv[0], tag: argv[1], repos: null, apply: false, ship: false, workdir: null };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--apply') out.apply = true;
++ else if (argv[i] === '--ship') out.ship = true;
else if (argv[i] === '--repos') out.repos = String(argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
else if (argv[i] === '--workdir') out.workdir = argv[++i];
else throw new Error(`unknown argument ${argv[i]}`);
@@@ -147,6 +154,219 @@ function repositories(only) {
return list.filter((r) => !r.isArchived && !r.isFork).map((r) => r.name).sort();
}
++// The size-budget test the bot measures after a bump: the first of these the repository has. All
++// report through openvibe-shared/perf-budget's format(): "over budget: <name> <value> > <budget>".
++// check-budgets.js comes first because Live has both and its test/perf-budget.test.js only shells to
++// scripts/perf/check-budgets.js, which holds the BUDGETS the raise rewrites.
++const BUDGET_FILES = ['scripts/perf/check-budgets.js', 'test/perf-budget.test.js', 'apps/gateway/test/perf-budget.test.js'];
++const OVER_BUDGET_RE = /^\s*over budget: ([A-Za-z]+) ([\d.]+) > ([\d.]+)/gm;
++
++/** The "over budget" lines of a budget run's output: [{ name, value, budget }]. */
++function parseOverBudget(output) {
++ const out = [];
++ for (const m of String(output || '').matchAll(OVER_BUDGET_RE)) {
++ const value = Number(m[2]);
++ const budget = Number(m[3]);
++ if (Number.isFinite(value) && Number.isFinite(budget)) out.push({ name: m[1], value, budget });
++ }
++ return out;
++}
++
++/** The measured value rounded up to the next 0.5 (85.2 → 85.5; 85 and 6 stay whole). */
++const nextHalf = (v) => Math.ceil(v * 2 - 1e-9) / 2;
++
++/**
++ * Raise the over-budget keys in a budget file's text to the measured value (next 0.5), each with a
++ * dated note naming the release; every other key and the logic stay untouched, and a key is never
++ * lowered. → { text, raised: [{ name, old, next, measured }] }
++ */
++function raiseBudgetText(text, over, { library, tag, date }) {
++ const raised = [];
++ let out = String(text);
++ for (const { name, value } of over) {
++ const next = nextHalf(value);
++ const m = new RegExp(`^([ \\t]*${name}:[ \\t]*)([0-9]+(?:\\.[0-9]+)?)(,?)(.*)$`, 'm').exec(out);
++ if (!m || !(next > Number(m[2]))) continue;
++ const tail = m[4].trim().replace(/^\/\/\s?/, '');
++ out = out.replace(m[0], `${m[1]}${next}${m[3]} // ${next} ${library} ${tag} (${date}: measured ${value}, raised by pin-bump from ${m[2]})${tail ? `; ${tail}` : ''}`);
++ raised.push({ name, old: m[2], next: String(next), measured: String(value) });
++ }
++ return { text: out, raised };
++}
++
++/**
++ * The repository's budget test, if it has one: { file, pkgDir } with the package directory to
++ * install and run it from (the gateway budget belongs to apps/gateway, the others to the root).
++ */
++function findBudgetFile(dir) {
++ for (const f of BUDGET_FILES) {
++ if (!fs.existsSync(path.join(dir, f))) continue;
++ let pkgDir = path.dirname(f);
++ for (;;) {
++ if (fs.existsSync(path.join(dir, pkgDir, 'package.json'))) break;
++ const up = path.dirname(pkgDir);
++ if (up === pkgDir) { pkgDir = '.'; break; }
++ pkgDir = up;
++ }
++ return { file: f, pkgDir };
++ }
++ return null;
++}
++
++const lastLine = (err) => String((err && (err.stdout || err.stderr)) || (err && err.message) || err).split('\n').filter(Boolean).slice(-1)[0];
++
++function realInstall(cwd) {
++ if (!fs.existsSync(path.join(cwd, 'package-lock.json'))) {
++ return fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))
++ ? { ok: false, skipped: `no package-lock.json in ${path.basename(cwd) || '.'} (pnpm budgets are not measured)` }
++ : { ok: false, skipped: `no package-lock.json in ${path.basename(cwd) || '.'}` };
++ }
++ try {
++ cp.execFileSync('npm', ['ci', '--no-audit', '--no-fund'], { cwd, encoding: 'utf8', timeout: 10 * 60 * 1000, stdio: ['ignore', 'pipe', 'pipe'] });
++ return { ok: true };
++ } catch (err) {
++ return { ok: false, skipped: `npm ci failed: ${lastLine(err)}` };
++ }
++}
++
++function realRun(cwd, relFile) {
++ try {
++ const output = cp.execFileSync('node', [relFile],
++ { cwd, encoding: 'utf8', timeout: 10 * 60 * 1000, stdio: ['ignore', 'pipe', 'pipe'] });
++ return { code: 0, output };
++ } catch (err) {
++ if (err.signal === 'SIGTERM') return { code: 1, output: '', timedOut: true };
++ return { code: err.status || 1, output: `${err.stdout || ''}${err.stderr || ''}` };
++ }
++}
++
++/**
++ * Measure the repository's size budgets after the bump and raise the keys the bump outgrew.
++ * Returns { raised, file } (raised is [] when the budgets are already green) or { raised: [],
++ * skipped } when the run cannot prove a raise. `io` ({ install, run }) is injected for tests.
++ */
++function raiseBudgets(dir, { library, tag, date = new Date().toISOString().slice(0, 10), io = null } = {}) {
++ const found = findBudgetFile(dir);
++ if (!found) return { raised: [], skipped: 'no budget test' };
++ const cwd = path.join(dir, found.pkgDir);
++ const rel = path.relative(found.pkgDir, found.file);
++ const install = io ? io.install : realInstall;
++ const runBudget = io ? io.run : realRun;
++ const installed = install(cwd);
++ if (!installed.ok) return { raised: [], skipped: installed.skipped };
++ const first = runBudget(cwd, rel);
++ if (first.timedOut) return { raised: [], skipped: 'the budget test timed out' };
++ if (first.code === 0) return { raised: [] };
++ const over = parseOverBudget(first.output);
++ if (!over.length) return { raised: [], skipped: 'the budget test failed without an "over budget" line' };
++ if (/is loaded by the home page/.test(first.output)) return { raised: [], skipped: 'forbidden files on the home page (not a budget)' };
++ const p = path.join(dir, found.file);
++ const { text, raised } = raiseBudgetText(fs.readFileSync(p, 'utf8'), over, { library, tag, date });
++ if (!raised.length) return { raised: [], skipped: `could not raise ${over.map((o) => o.name).join(', ')}` };
++ fs.writeFileSync(p, text);
++ const second = runBudget(cwd, rel);
++ if (second.code !== 0) {
++ const still = parseOverBudget(second.output).map((o) => `${o.name} ${o.value} > ${o.budget}`).join(', ');
++ return { raised, file: found.file, verify: still || 'still failing' };
++ }
++ return { raised, file: found.file };
++}
++
++/**
++ * One table: every OpenVibers repository → its ovhost service, or null when a pin PR only merges
++ * (the four libraries, and repositories with no service). Built from the 4.190 round's ship list
++ * (57 entries) and `ovhost status --json`, plus OpenVibe-Git → git (openvibe-git.service, the
++ * git.openvibe.codes host) and OpenRestream → openre (its deploy docs). Live and Media ship last
++ * and never with --force: they wait for idle by themselves (ov-ship).
++ */
++const SERVICE = {
++ AFResume: null,
++ BreakRoomSimulator: null,
++ OpenRestream: 'openre',
++ 'OpenVibe-Git': 'git',
++ 'OpenVibe.AI': 'ai',
++ 'OpenVibe.Actor': 'actor',
++ 'OpenVibe.Bench': 'bench',
++ 'OpenVibe.Billing': 'billing',
++ 'OpenVibe.Blog': 'blog',
++ 'OpenVibe.Bot': 'bot',
++ 'OpenVibe.Cam': 'cam',
++ 'OpenVibe.Casino': 'casino',
++ 'OpenVibe.Chat': 'chat',
++ 'OpenVibe.Codes': 'codes',
++ 'OpenVibe.Community': 'community',
++ 'OpenVibe.Computer': 'computer',
++ 'OpenVibe.Connect': 'connect',
++ 'OpenVibe.Contracts': null,
++ 'OpenVibe.Coupons': 'coupons',
++ 'OpenVibe.Deals': 'deals',
++ 'OpenVibe.Design': 'design',
++ 'OpenVibe.Domains': 'domains',
++ 'OpenVibe.Events': 'events',
++ 'OpenVibe.Examples': null,
++ 'OpenVibe.Exchange': 'exchange',
++ 'OpenVibe.Extensions': null,
++ 'OpenVibe.Food': 'food',
++ 'OpenVibe.Fun': 'fun',
++ 'OpenVibe.Games': 'games',
++ 'OpenVibe.Help': 'help',
++ 'OpenVibe.Host': 'host',
++ 'OpenVibe.Inventory': 'inventory',
++ 'OpenVibe.Link': null,
++ 'OpenVibe.Live': 'live',
++ 'OpenVibe.Locker': 'locker',
++ 'OpenVibe.Mail': 'mail',
++ 'OpenVibe.Media': 'media',
++ 'OpenVibe.MediaHub': 'media-hub',
++ 'OpenVibe.Meme': 'meme',
++ 'OpenVibe.Network': 'network',
++ 'OpenVibe.News': 'news',
++ 'OpenVibe.Play': 'play',
++ 'OpenVibe.Publishing': null,
++ 'OpenVibe.Quest': 'quest',
++ 'OpenVibe.Rent': 'rent',
++ 'OpenVibe.Report': 'report',
++ 'OpenVibe.Reviews': 'reviews',
++ 'OpenVibe.Run': 'run',
++ 'OpenVibe.SDK': null,
++ 'OpenVibe.Search': 'search',
++ 'OpenVibe.Services': 'services',
++ 'OpenVibe.Shared': null,
++ 'OpenVibe.Shoes': 'shoes',
++ 'OpenVibe.Sources': 'sources',
++ 'OpenVibe.Space': 'space',
++ 'OpenVibe.Studio': 'studio',
++ 'OpenVibe.Surf': 'surf',
++ 'OpenVibe.Tips': 'tips',
++ 'OpenVibe.Today': 'today',
++ 'OpenVibe.Tools': 'tools',
++ 'OpenVibe.Tours': 'tours',
++ 'OpenVibe.Trade': 'trade',
++ 'OpenVibe.VIP': 'vip',
++ 'OpenVibe.Vision': 'vision',
++ 'OpenVibe.Watch': 'watch',
++ 'OpenVibe.Wiki': 'wiki',
++ 'OpenVibe.Work': 'work',
++ 'OpenVibe.Zone': 'zone',
++ OpenVibers: null,
++ 'Source.OpenVibe.Games': null,
++ 'openvibe-agents': null,
++};
++
++/** Ship order: the round's order, except Live and then Media go last. Stable. */
++function shipOrder(ships) {
++ const rank = (s) => (s.repo === 'OpenVibe.Media' ? 2 : s.repo === 'OpenVibe.Live' ? 1 : 0);
++ return [...ships].sort((a, b) => rank(a) - rank(b));
++}
++
++/** The merge command for one shipped PR: merge-and-deploy with its service, or merge-only without one. */
++function shipCommand(tools, { repo, number }) {
++ const svc = Object.hasOwn(SERVICE, repo) ? SERVICE[repo] : undefined;
++ if (svc === undefined) return { run: null, describe: `${repo}#${number}: no service mapping in pin-bump.js SERVICE (a new repository?); ship it by hand` };
++ if (svc) return { run: [path.join(tools, 'merge-deploy.sh'), `${repo}#${number}:${svc}`], describe: `merge-deploy.sh '${repo}#${number}:${svc}'` };
++ return { run: [path.join(tools, 'merge-after-ci.sh'), `${repo}#${number}`], describe: `merge-after-ci.sh '${repo}#${number}'` };
++}
++
function main() {
const opts = parseArgs(process.argv.slice(2));
const { library, tag } = opts;
@@@ -161,7 +381,10 @@ function main() {
console.error(`pin-bump: ${err.message}; the Shared security workflow pins are left alone`);
}
let failed = 0;
++ let shipFailed = 0;
const summary = [];
++ const ships = [];
++ const tools = path.join(os.homedir(), 'openvibe/work/tools');
for (const repo of repositories(opts.repos)) {
if (repo === LIBS[library]) continue;
const dir = path.join(work, repo);
@@@ -189,10 +412,22 @@ function main() {
if (staleSecurity.length) updateSecurityPins(dir, sharedSha).forEach((f) => changed.add(f));
const from = [...new Set(stale.flatMap((s) => s.from))].sort((a, b) => cmp(parse(a), parse(b)));
if (stale.length) updateVersionLines(dir, library, from, tag).forEach((f) => changed.add(f));
++ // With --apply the bump is measured and the budgets it outgrew join the same commit, so the
++ // PR opens green; a dry run only says which budget test would be measured.
++ let budgets = null;
++ if (stale.length && opts.apply) {
++ budgets = raiseBudgets(dir, { library, tag });
++ if (budgets.raised && budgets.raised.length) changed.add(budgets.file);
++ }
const title = stale.length
? `${library} ${tag} (was ${from.join(', ')})`
: `CI: pin the OpenVibe.Shared security workflow at ${sharedSha.slice(0, 12)}`;
-- if (!opts.apply) { summary.push(`${repo}: would open "${title}" changing ${[...changed].join(', ')}`); continue; }
++ if (!opts.apply) {
++ const budget = stale.length && findBudgetFile(dir) ? ` (measures ${findBudgetFile(dir).file} on apply)` : '';
++ summary.push(`${repo}: would open "${title}" changing ${[...changed].join(', ')}${budget}`);
++ if (opts.ship) ships.push({ repo, number: '?' });
++ continue;
++ }
run('git', ['-C', dir, 'checkout', '-q', '-b', target]);
run('git', ['-C', dir, 'add', '--', ...changed]);
run('git', ['-C', dir, ...AUTHOR, 'commit', '-q', '-m', `${title}\n\nOpened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the ${tag} release.`]);
@@@ -200,12 +435,28 @@ function main() {
const body = [
...(stale.length ? [`Moves every \`${library}\` pin in this repository to ${tag} (from ${from.join(', ')}), refreshes the lockfile and the`, 'version lines in STATUS.json / README.md.'] : []),
...(staleSecurity.length ? [`Moves the OpenVibe.Shared security workflow pin to ${sharedSha}, the newest Shared main commit that changed \`.github/workflows\`.`] : []),
++ ...(budgets && budgets.raised.length ? [`Size budgets raised to this bump's measurement: ${budgets.raised.map((r) => `\`${r.name}\` ${r.old} → ${r.next} (measured ${r.measured})`).join(', ')}.`] : []),
++ ...(budgets && budgets.verify ? [`Budgets raised but the budget test still fails (${budgets.verify}); the round driver fixes the rest.`] : []),
'CI runs the full suite and pin drift; merge when it is green.',
'', 'Files: ' + [...changed].map((f) => `\`${f}\``).join(', '),
'', `Opened by \`scripts/pin-bump.js\` (OpenVibers/OpenVibe.Shared) after the ${LIBS[library]} ${tag} release.`,
].join('\n');
const url = run('gh', ['pr', 'create', '-R', `${ORG}/${repo}`, '--head', target, '--title', title, '--body', body]);
-- summary.push(`${repo}: ${url}`);
++ let note = '';
++ if (budgets && budgets.raised.length) note = ` (budgets: ${budgets.raised.map((r) => `${r.name} ${r.old} → ${r.next}`).join(', ')})`;
++ else if (budgets && budgets.skipped && budgets.skipped !== 'no budget test') note = ` (budgets not measured: ${budgets.skipped})`;
++ if (budgets && budgets.verify) note += ` (budgets still red: ${budgets.verify})`;
++ summary.push(`${repo}: ${url}${note}`);
++ if (opts.ship) {
++ let number = (url.match(/\/pull\/(\d+)\s*$/) || [])[1];
++ if (!number) {
++ try {
++ number = run('gh', ['pr', 'list', '-R', `${ORG}/${repo}`, '--head', target, '--state', 'open', '--json', 'number', '--jq', '.[0].number']);
++ } catch { number = null; }
++ }
++ if (number) ships.push({ repo, number });
++ else summary.push(`${repo}: ship: the PR number is unknown; ship ${url} by hand`);
++ }
} catch (err) {
failed++;
summary.push(`${repo}: FAILED ${String(err.stderr || err.message).split('\n').filter(Boolean).slice(-1)[0] || err.message}`);
@@@ -214,11 +465,37 @@ function main() {
if (!opts.workdir) fs.rmSync(work, { recursive: true, force: true });
console.log(`pin-bump ${library} ${tag}${opts.apply ? '' : ' (dry run)'}`);
for (const line of summary) console.log(` ${line}`);
-- process.exitCode = failed ? 1 : 0;
++ if (opts.ship) {
++ const ordered = shipOrder(ships);
++ if (!opts.apply) {
++ console.log(`ship plan (dry run): ${ordered.length} PRs, one at a time, Live and Media last:`);
++ for (const s of ordered) console.log(` ${shipCommand(tools, s).describe}`);
++ } else if (!ordered.length) {
++ console.log('ship: no PRs were opened; nothing to ship');
++ } else if (!fs.existsSync(path.join(tools, 'merge-deploy.sh'))) {
++ console.error(`ship: ${tools}/merge-deploy.sh is missing; ship the ${ordered.length} PRs by hand`);
++ shipFailed = 1;
++ } else {
++ // One at a time: merge-deploy.sh waits for the PR's checks, merges, then deploys (the host
++ // runs out of memory on parallel builds, and Live/Media wait for idle by themselves).
++ for (const s of ordered) {
++ const cmd = shipCommand(tools, s);
++ if (!cmd.run) { console.log(` ${cmd.describe}`); continue; }
++ try {
++ const out = cp.execFileSync(cmd.run[0], cmd.run.slice(1), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
++ console.log(` ${s.repo}#${s.number}: ${out.trim().split('\n').filter(Boolean).slice(-1)[0]}`);
++ } catch (err) {
++ shipFailed = 1;
++ console.log(` ${s.repo}#${s.number}: SHIP FAILED ${String(err.stderr || err.message).split('\n').filter(Boolean).slice(-1)[0] || ''}`.trimEnd());
++ }
++ }
++ }
++ }
++ process.exitCode = failed || shipFailed ? 1 : 0;
}
if (require.main === module) {
try { main(); } catch (err) { console.error(`pin-bump: ${err.message}`); process.exitCode = 1; }
}
--module.exports = { stalePins, staleSecurityPins, updateSecurityPins, updateVersionLines, pinUrl, parseArgs };
++module.exports = { stalePins, staleSecurityPins, updateSecurityPins, updateVersionLines, pinUrl, parseArgs, parseOverBudget, nextHalf, raiseBudgetText, findBudgetFile, raiseBudgets, SERVICE, shipOrder, shipCommand };
ddiff --git a/test/pin-bump.test.js b/test/pin-bump.test.js
iindex 22cc542..75bc8e8 100644
---- a/test/pin-bump.test.js
++++ b/test/pin-bump.test.js
@@@ -6,7 +6,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const cp = require('child_process');
--const { stalePins, staleSecurityPins, updateSecurityPins, updateVersionLines, pinUrl, parseArgs } = require('../scripts/pin-bump');
++const { stalePins, staleSecurityPins, updateSecurityPins, updateVersionLines, pinUrl, parseArgs, parseOverBudget, nextHalf, raiseBudgetText, findBudgetFile, raiseBudgets, SERVICE, shipOrder, shipCommand } = require('../scripts/pin-bump');
const url = (repo, v) => `https://codeload.github.com/OpenVibers/${repo}/tar.gz/refs/tags/${v}`;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-bump-'));
@@@ -57,9 +57,112 @@ assert.match(status.contracts, /v0\.112\.0/);
assert.match(status.sdk, /v0\.26\.0/, 'the other library is untouched');
assert.strictEqual(pinUrl('openvibe-sdk', 'v0.35.0'), url('OpenVibe.SDK', 'v0.35.0'));
--assert.deepStrictEqual(parseArgs(['openvibe-shared', '2.13.0', '--repos', 'A, B', '--apply']), { library: 'openvibe-shared', tag: 'v2.13.0', repos: ['A', 'B'], apply: true, workdir: null });
++assert.deepStrictEqual(parseArgs(['openvibe-shared', '2.13.0', '--repos', 'A, B', '--apply']), { library: 'openvibe-shared', tag: 'v2.13.0', repos: ['A', 'B'], apply: true, ship: false, workdir: null });
++assert.strictEqual(parseArgs(['openvibe-shared', 'v2.13.0', '--ship']).ship, true);
++assert.strictEqual(parseArgs(['openvibe-shared', 'v2.13.0', '--ship']).apply, false, '--ship is a dry run without --apply');
assert.throws(() => parseArgs(['left-pad', 'v1.0.0']), /library must be one of/);
assert.throws(() => parseArgs(['openvibe-sdk', 'latest']), /tag must look like/);
++// The budget raise: "over budget" lines parse, the measured value rounds up to the next 0.5, and only
++// the named keys move, each with a dated note naming the release.
++assert.deepStrictEqual(parseOverBudget('https://x/: html 86.5 KB\n over budget: jsRawKB 392.5 > 376.7\n over budget: jsFiles 6 > 5\n'), [
++ { name: 'jsRawKB', value: 392.5, budget: 376.7 },
++ { name: 'jsFiles', value: 6, budget: 5 },
++]);
++assert.deepStrictEqual(parseOverBudget('perf budget: all checks passed\n'), []);
++assert.deepStrictEqual([nextHalf(85.2), nextHalf(85.5), nextHalf(85), nextHalf(6), nextHalf(392.5)], [85.5, 85.5, 85, 6, 392.5]);
++
++const budgetSrc = [
++ 'const BUDGETS = {',
++ ' jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost)',
++ ' jsRawKB: 376.7, // 376.7: measured 368.7 with openvibe-shared 4.163.0',
++ ' jsBrotliKB: 89.8, // 89.8: measured 89.7 with openvibe-shared 4.186.0',
++ '};',
++ "console.log('the server on port ' + PORT);",
++].join('\n');
++{
++ const { text, raised } = raiseBudgetText(budgetSrc,
++ [{ name: 'jsRawKB', value: 392.5, budget: 376.7 }, { name: 'jsFiles', value: 6, budget: 5 }],
++ { library: 'openvibe-shared', tag: 'v4.190.0', date: '2026-11-10' });
++ assert.deepStrictEqual(raised, [
++ { name: 'jsRawKB', old: '376.7', next: '392.5', measured: '392.5' },
++ { name: 'jsFiles', old: '5', next: '6', measured: '6' },
++ ]);
++ assert.ok(text.includes('jsRawKB: 392.5, // 392.5 openvibe-shared v4.190.0 (2026-11-10: measured 392.5, raised by pin-bump from 376.7); 376.7: measured 368.7'), 'the note names the release and keeps the history');
++ assert.ok(text.includes('jsFiles: 6, // 6 openvibe-shared v4.190.0 (2026-11-10: measured 6, raised by pin-bump from 5); 5 (theme-loader'), 'a file count stays whole');
++ assert.ok(text.includes(' jsBrotliKB: 89.8, // 89.8: measured 89.7 with openvibe-shared 4.186.0'), 'other keys stay');
++ assert.ok(text.includes("console.log('the server on port ' + PORT);"), 'the logic stays');
++}
++{
++ // Never lowered, never churned, unknown keys left alone.
++ const { text, raised } = raiseBudgetText(budgetSrc,
++ [{ name: 'jsRawKB', value: 370, budget: 376.7 }, { name: 'nope', value: 9, budget: 1 }],
++ { library: 'openvibe-shared', tag: 'v4.190.0', date: '2026-11-10' });
++ assert.deepStrictEqual([raised, text === budgetSrc], [[], true]);
++}
++
++// End to end on a fixture repository: the bump's measurement raises the budgets, a green run raises
++// nothing, and anything unprovable is skipped with a reason (the install and the run are faked;
++// the file rewrite is real).
++const bdir = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-bump-budget-'));
++const bwrite = (f, v) => { fs.mkdirSync(path.dirname(path.join(bdir, f)), { recursive: true }); fs.writeFileSync(path.join(bdir, f), v); };
++bwrite('package.json', JSON.stringify({ name: 'fixture', dependencies: { 'openvibe-shared': url('OpenVibe.Shared', 'v4.190.0') } }));
++bwrite('test/perf-budget.test.js', budgetSrc);
++bwrite('apps/gateway/package.json', JSON.stringify({ name: 'gateway' }));
++bwrite('apps/gateway/test/perf-budget.test.js', budgetSrc);
++assert.deepStrictEqual(findBudgetFile(bdir), { file: 'test/perf-budget.test.js', pkgDir: '.' });
++bwrite('scripts/perf/check-budgets.js', budgetSrc);
++assert.deepStrictEqual(findBudgetFile(bdir), { file: 'scripts/perf/check-budgets.js', pkgDir: '.' }, "Live's wrapper defers to the script holding BUDGETS");
++fs.rmSync(path.join(bdir, 'scripts'), { recursive: true, force: true });
++fs.rmSync(path.join(bdir, 'test'), { recursive: true, force: true });
++assert.deepStrictEqual(findBudgetFile(bdir), { file: 'apps/gateway/test/perf-budget.test.js', pkgDir: 'apps/gateway' }, "the gateway budget installs in apps/gateway");
++assert.strictEqual(findBudgetFile(dir), null, 'a repository without a budget test');
++bwrite('test/perf-budget.test.js', budgetSrc);
++{
++ const calls = [];
++ const io = {
++ install: (cwd) => { calls.push(['install', cwd]); return { ok: true }; },
++ run: (cwd, rel) => {
++ calls.push(['run', cwd, rel]);
++ return calls.filter((c) => c[0] === 'run').length === 1
++ ? { code: 1, output: ' over budget: jsBrotliKB 93.7 > 89.8\n' }
++ : { code: 0, output: 'perf budget: all checks passed\n' };
++ },
++ };
++ const r = raiseBudgets(bdir, { library: 'openvibe-shared', tag: 'v4.190.0', date: '2026-11-10', io });
++ assert.deepStrictEqual(r.raised, [{ name: 'jsBrotliKB', old: '89.8', next: '94', measured: '93.7' }]);
++ assert.strictEqual(r.file, 'test/perf-budget.test.js');
++ assert.ok(fs.readFileSync(path.join(bdir, 'test/perf-budget.test.js'), 'utf8').includes('jsBrotliKB: 94, // 94 openvibe-shared v4.190.0 (2026-11-10: measured 93.7, raised by pin-bump from 89.8)'), 'the file on disk moved');
++ assert.deepStrictEqual(calls.filter((c) => c[0] === 'run').length, 2, 'the raise is verified by a second run');
++ assert.deepStrictEqual(calls[0], ['install', bdir], 'the install runs in the package directory');
++}
++{
++ const green = { install: () => ({ ok: true }), run: () => ({ code: 0, output: 'perf budget: all checks passed\n' }) };
++ assert.deepStrictEqual(raiseBudgets(bdir, { library: 'openvibe-shared', tag: 'v4.190.0', io: green }), { raised: [] });
++ const failing = { install: () => ({ ok: true }), run: () => ({ code: 1, output: 'the server did not start\n' }) };
++ assert.match(raiseBudgets(bdir, { library: 'openvibe-shared', tag: 'v4.190.0', io: failing }).skipped, /without an "over budget" line/);
++ const forbidden = { install: () => ({ ok: true }), run: () => ({ code: 1, output: ' over budget: jsRawKB 1400 > 958.3\n✗ /js/chat.js is loaded by the home page\n' }) };
++ assert.match(raiseBudgets(bdir, { library: 'openvibe-shared', tag: 'v4.190.0', io: forbidden }).skipped, /forbidden files/);
++ const broken = { install: () => ({ ok: false, skipped: 'npm ci failed: EAI_AGAIN' }), run: () => { throw new Error('must not run'); } };
++ assert.match(raiseBudgets(bdir, { library: 'openvibe-shared', tag: 'v4.190.0', io: broken }).skipped, /npm ci failed/);
++}
++
++// The ship table: services from the round's ship list, null for libraries and the serviceless, and
++// Live and Media last.
++assert.strictEqual(SERVICE['OpenVibe.Quest'], 'quest');
++assert.strictEqual(SERVICE['OpenVibe.MediaHub'], 'media-hub');
++assert.strictEqual(SERVICE['OpenVibe-Git'], 'git');
++assert.strictEqual(SERVICE.OpenRestream, 'openre');
++for (const lib of ['OpenVibe.Contracts', 'OpenVibe.SDK', 'OpenVibe.Publishing', 'OpenVibe.Shared']) assert.strictEqual(SERVICE[lib], null, `${lib} merges without a deploy`);
++for (const repo of ['OpenVibe.Examples', 'OpenVibe.Extensions', 'OpenVibe.Link', 'AFResume', 'openvibe-agents']) assert.strictEqual(SERVICE[repo], null, `${repo} has no service`);
++assert.ok(Object.values(SERVICE).every((s) => s === null || /^[a-z][a-z0-9-]*$/.test(s)), 'every service is an ovhost name or null');
++assert.deepStrictEqual(
++ shipOrder([{ repo: 'OpenVibe.Live', number: 1 }, { repo: 'OpenVibe.AI', number: 2 }, { repo: 'OpenVibe.Media', number: 3 }]).map((s) => s.repo),
++ ['OpenVibe.AI', 'OpenVibe.Live', 'OpenVibe.Media']);
++assert.deepStrictEqual(shipCommand('/tools', { repo: 'OpenVibe.Quest', number: 263 }), { run: [path.join('/tools', 'merge-deploy.sh'), 'OpenVibe.Quest#263:quest'], describe: "merge-deploy.sh 'OpenVibe.Quest#263:quest'" });
++assert.deepStrictEqual(shipCommand('/tools', { repo: 'OpenVibe.Contracts', number: 7 }), { run: [path.join('/tools', 'merge-after-ci.sh'), 'OpenVibe.Contracts#7'], describe: "merge-after-ci.sh 'OpenVibe.Contracts#7'" });
++assert.strictEqual(shipCommand('/tools', { repo: 'OpenVibe.New', number: 1 }).run, null, 'an unmapped repository is shipped by hand, never guessed');
++
++fs.rmSync(bdir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('pin-bump: all checks passed');