openvibers/openvibe.shared Commits
Pin bot: raise budgets itself and ship the round (#431)
Changes
@@@ -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 };@@@ -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');
@@ -7,14 +7,20 @@
@@ -7,14 +7,20 @@
* version the repository's STATUS.json and README.md name for the library, and moves the repository's
* 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
* 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).
* .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
* 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)
* 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
* --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)
* --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)
* --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
* 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 = {}) {
@@ -41,9 +47,10 @@ function run(cmd, args, opts = {}) {
}
}
function parseArgs(argv) {
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++) {
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--apply') out.apply = true;
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] === '--repos') out.repos = String(argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
else if (argv[i] === '--workdir') out.workdir = argv[++i];
else if (argv[i] === '--workdir') out.workdir = argv[++i];
else throw new Error(`unknown argument ${argv[i]}`);
else throw new Error(`unknown argument ${argv[i]}`);
@@ -147,6 +154,219 @@ function repositories(only) {
@@ -147,6 +154,219 @@ function repositories(only) {
return list.filter((r) => !r.isArchived && !r.isFork).map((r) => r.name).sort();
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() {
function main() {
const opts = parseArgs(process.argv.slice(2));
const opts = parseArgs(process.argv.slice(2));
const { library, tag } = opts;
const { library, tag } = opts;
@@ -161,7 +381,10 @@ function main() {
@@ -161,7 +381,10 @@ function main() {
console.error(`pin-bump: ${err.message}; the Shared security workflow pins are left alone`);
console.error(`pin-bump: ${err.message}; the Shared security workflow pins are left alone`);
}
}
let failed = 0;
let failed = 0;
+ let shipFailed = 0;
const summary = [];
const summary = [];
+ const ships = [];
+ const tools = path.join(os.homedir(), 'openvibe/work/tools');
for (const repo of repositories(opts.repos)) {
for (const repo of repositories(opts.repos)) {
if (repo === LIBS[library]) continue;
if (repo === LIBS[library]) continue;
const dir = path.join(work, repo);
const dir = path.join(work, repo);
@@ -189,10 +412,22 @@ function main() {
@@ -189,10 +412,22 @@ function main() {
if (staleSecurity.length) updateSecurityPins(dir, sharedSha).forEach((f) => changed.add(f));
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)));
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));
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
const title = stale.length
? `${library} ${tag} (was ${from.join(', ')})`
? `${library} ${tag} (was ${from.join(', ')})`
: `CI: pin the OpenVibe.Shared security workflow at ${sharedSha.slice(0, 12)}`;
: `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, 'checkout', '-q', '-b', target]);
run('git', ['-C', dir, 'add', '--', ...changed]);
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.`]);
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() {
@@ -200,12 +435,28 @@ function main() {
const body = [
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.'] : []),
...(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\`.`] : []),
...(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.',
'CI runs the full suite and pin drift; merge when it is green.',
'', 'Files: ' + [...changed].map((f) => `\`${f}\``).join(', '),
'', 'Files: ' + [...changed].map((f) => `\`${f}\``).join(', '),
'', `Opened by \`scripts/pin-bump.js\` (OpenVibers/OpenVibe.Shared) after the ${LIBS[library]} ${tag} release.`,
'', `Opened by \`scripts/pin-bump.js\` (OpenVibers/OpenVibe.Shared) after the ${LIBS[library]} ${tag} release.`,
].join('\n');
].join('\n');
const url = run('gh', ['pr', 'create', '-R', `${ORG}/${repo}`, '--head', target, '--title', title, '--body', body]);
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) {
} catch (err) {
failed++;
failed++;
summary.push(`${repo}: FAILED ${String(err.stderr || err.message).split('\n').filter(Boolean).slice(-1)[0] || err.message}`);
summary.push(`${repo}: FAILED ${String(err.stderr || err.message).split('\n').filter(Boolean).slice(-1)[0] || err.message}`);
@@ -214,11 +465,37 @@ function main() {
@@ -214,11 +465,37 @@ function main() {
if (!opts.workdir) fs.rmSync(work, { recursive: true, force: true });
if (!opts.workdir) fs.rmSync(work, { recursive: true, force: true });
console.log(`pin-bump ${library} ${tag}${opts.apply ? '' : ' (dry run)'}`);
console.log(`pin-bump ${library} ${tag}${opts.apply ? '' : ' (dry run)'}`);
for (const line of summary) console.log(` ${line}`);
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) {
if (require.main === module) {
try { main(); } catch (err) { console.error(`pin-bump: ${err.message}`); process.exitCode = 1; }
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 };
@@ -6,7 +6,7 @@ const fs = require('fs');
@@ -6,7 +6,7 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const cp = require('child_process');
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 url = (repo, v) => `https://codeload.github.com/OpenVibers/${repo}/tar.gz/refs/tags/${v}`;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-bump-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-bump-'));
@@ -57,9 +57,112 @@ assert.match(status.contracts, /v0\.112\.0/);
@@ -57,9 +57,112 @@ assert.match(status.contracts, /v0\.112\.0/);
assert.match(status.sdk, /v0\.26\.0/, 'the other library is untouched');
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.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(['left-pad', 'v1.0.0']), /library must be one of/);
assert.throws(() => parseArgs(['openvibe-sdk', 'latest']), /tag must look like/);
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 });
fs.rmSync(dir, { recursive: true, force: true });
console.log('pin-bump: all checks passed');
console.log('pin-bump: all checks passed');