openvibers/openvibe.shared Commits

Shared: scripts/pin-bump.js, the pin bot that opens a PR per repository after a library release (plan T1) (#25)

OP OpenVibers committed 7 Oct 2026 · 05f2c32928 · 4 files, +208 −0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f6ff954..4d46e26 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,11 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++- `scripts/pin-bump.js` (plan T1, the pin bot): after a release of openvibe-contracts, -shared, -sdk or -publishing,
++ `node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]` opens one PR per OpenVibers repository that pins
++ the library below the tag: every pin moved, the npm or pnpm lockfile refreshed, STATUS.json and the README versions
++ block rewritten by `scripts/docs-status.js`. A dry run by default; CI tests each PR (full suite and pin drift).
++
## 2.13.0 — 2026-10-07
- `release-watch` now connects to `https://openvibe.events` by default; the notification bell follows the stream URL in Network's ticket. A site pinning this release must allow `connect-src https://openvibe.events` in its CSP.
ddiff --git a/README.md b/README.md
iindex 854947d..5cb8f30 100644
---- a/README.md
++++ b/README.md
@@@ -23,6 +23,7 @@ were reconciled first.
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
++| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
Every module file has an explicit `exports` entry (no wildcards), so `require('openvibe-shared/navbar')`,
ddiff --git a/scripts/pin-bump.js b/scripts/pin-bump.js
nnew file mode 100644
iindex 0000000..e7359f9
---- /dev/null
++++ b/scripts/pin-bump.js
@@@ -0,0 +1,159 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Pin bump (plan T1: "a bot bumps pins on each Contracts release"): after a library release, open one pull request
++ * per OpenVibers repository whose package.json files pin that library below the new tag. Each PR moves every pin of
++ * the library in the repository to the tag, refreshes the lockfile that goes with it (npm or pnpm), and updates the
++ * version the repository's STATUS.json and README.md name for the library. 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.
++ *
++ * node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply] [--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)
++ * --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
++ * and opens the PRs). Commits are made as OpenVibers <[email protected]>. A repository that already has an
++ * open PR from the same branch (pin-bump/<library>-<tag>) is left alone. Exit 0 = done, 1 = a repository failed.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const cp = require('child_process');
++
++const LIBS = { 'openvibe-contracts': 'OpenVibe.Contracts', 'openvibe-shared': 'OpenVibe.Shared', 'openvibe-sdk': 'OpenVibe.SDK', 'openvibe-publishing': 'OpenVibe.Publishing' };
++const ORG = 'OpenVibers';
++const AUTHOR = ['-c', 'user.name=OpenVibers', '-c', '[email protected]'];
++const parse = (t) => { const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(t); return m ? m.slice(1).map(Number) : null; };
++const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
++
++function run(cmd, args, opts = {}) {
++ return cp.execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
++}
++
++function parseArgs(argv) {
++ const out = { library: argv[0], tag: argv[1], repos: null, apply: false, workdir: null };
++ for (let i = 2; i < argv.length; i++) {
++ if (argv[i] === '--apply') out.apply = 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]}`);
++ }
++ if (!LIBS[out.library]) throw new Error(`library must be one of ${Object.keys(LIBS).join(', ')}`);
++ if (!parse(out.tag)) throw new Error('tag must look like v1.2.3');
++ if (!out.tag.startsWith('v')) out.tag = `v${out.tag}`;
++ return out;
++}
++
++/** The pin URL of `library` at `tag`. */
++const pinUrl = (library, tag) => `https://codeload.github.com/${ORG}/${LIBS[library]}/tar.gz/refs/tags/${tag}`;
++const PIN_RE = (library) => new RegExp(`https://codeload\\.github\\.com/${ORG}/${LIBS[library].replace('.', '\\.')}/tar\\.gz/refs/tags/(v\\d+\\.\\d+\\.\\d+)`, 'g');
++
++/** Every package.json the repository tracks, with the pins of `library` it holds below `tag`. */
++function stalePins(dir, library, tag) {
++ const target = parse(tag);
++ const files = run('git', ['-C', dir, 'ls-files', '*package.json', 'package.json']).split('\n').filter(Boolean)
++ .filter((f) => !f.includes('node_modules/'));
++ const out = [];
++ for (const f of [...new Set(files)]) {
++ const text = fs.readFileSync(path.join(dir, f), 'utf8');
++ const found = [...text.matchAll(PIN_RE(library))].map((m) => m[1]);
++ const below = found.filter((v) => cmp(parse(v), target) < 0);
++ if (below.length) out.push({ file: f, from: [...new Set(below)] });
++ }
++ return out;
++}
++
++/** Refresh the lockfile that belongs to a bumped package.json: pnpm at the root if the repository uses it, else npm beside it. */
++function refreshLock(dir, file) {
++ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) {
++ run('pnpm', ['install', '--lockfile-only', '--ignore-scripts'], { cwd: dir });
++ return 'pnpm-lock.yaml';
++ }
++ const pkgDir = path.join(dir, path.dirname(file));
++ if (fs.existsSync(path.join(pkgDir, 'package-lock.json'))) {
++ run('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: pkgDir });
++ return path.join(path.dirname(file), 'package-lock.json');
++ }
++ return null;
++}
++
++/**
++ * STATUS.json's version fields and the README versions block follow the new pins (scripts/docs-status.js, plan D41):
++ * the files docs-status rewrote are returned so they join the commit.
++ */
++function updateVersionLines(dir) {
++ const before = Object.fromEntries(['STATUS.json', 'README.md'].filter((f) => fs.existsSync(path.join(dir, f)))
++ .map((f) => [f, fs.readFileSync(path.join(dir, f), 'utf8')]));
++ require('./docs-status').update(dir);
++ return Object.keys(before).filter((f) => fs.readFileSync(path.join(dir, f), 'utf8') !== before[f]);
++}
++
++function repositories(only) {
++ if (only && only.length) return only;
++ const list = JSON.parse(run('gh', ['repo', 'list', ORG, '--limit', '200', '--json', 'name,isArchived,isFork']));
++ return list.filter((r) => !r.isArchived && !r.isFork).map((r) => r.name).sort();
++}
++
++function main() {
++ const opts = parseArgs(process.argv.slice(2));
++ const { library, tag } = opts;
++ const tags = run('git', ['ls-remote', '--tags', `https://github.com/${ORG}/${LIBS[library]}.git`]);
++ if (!tags.split('\n').some((l) => l.endsWith(`refs/tags/${tag}`))) throw new Error(`${tag} is not published in ${ORG}/${LIBS[library]}`);
++ const work = opts.workdir || fs.mkdtempSync(path.join(os.tmpdir(), 'pin-bump-'));
++ const branch = `pin-bump/${library}-${tag}`;
++ let failed = 0;
++ const summary = [];
++ for (const repo of repositories(opts.repos)) {
++ if (repo === LIBS[library]) continue;
++ const dir = path.join(work, repo);
++ try {
++ run('git', ['clone', '-q', '--depth', '1', `https://github.com/${ORG}/${repo}.git`, dir]);
++ const stale = stalePins(dir, library, tag);
++ if (!stale.length) { summary.push(`${repo}: current`); continue; }
++ if (opts.apply) {
++ const open = run('gh', ['pr', 'list', '-R', `${ORG}/${repo}`, '--head', branch, '--state', 'open', '--json', 'number']);
++ if (JSON.parse(open).length) { summary.push(`${repo}: an open PR from ${branch} already exists`); continue; }
++ }
++ const changed = new Set();
++ for (const { file } of stale) {
++ const p = path.join(dir, file);
++ fs.writeFileSync(p, fs.readFileSync(p, 'utf8').replace(PIN_RE(library), pinUrl(library, tag)));
++ changed.add(file);
++ const lock = refreshLock(dir, file);
++ if (lock) changed.add(lock);
++ }
++ updateVersionLines(dir).forEach((f) => changed.add(f));
++ const from = [...new Set(stale.flatMap((s) => s.from))].sort((a, b) => cmp(parse(a), parse(b)));
++ const title = `${library} ${tag} (was ${from.join(', ')})`;
++ if (!opts.apply) { summary.push(`${repo}: would open "${title}" changing ${[...changed].join(', ')}`); continue; }
++ run('git', ['-C', dir, 'checkout', '-q', '-b', branch]);
++ 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, 'push', '-q', 'origin', branch]);
++ const body = [
++ `Moves every \`${library}\` pin in this repository to ${tag} (from ${from.join(', ')}), refreshes the lockfile and the`,
++ 'version lines in STATUS.json / README.md. 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', branch, '--title', title, '--body', body]);
++ summary.push(`${repo}: ${url}`);
++ } catch (err) {
++ failed++;
++ summary.push(`${repo}: FAILED ${String(err.stderr || err.message).split('\n').filter(Boolean).slice(-1)[0] || err.message}`);
++ }
++ }
++ 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 (require.main === module) {
++ try { main(); } catch (err) { console.error(`pin-bump: ${err.message}`); process.exitCode = 1; }
++}
++
++module.exports = { stalePins, updateVersionLines, pinUrl, parseArgs };
ddiff --git a/test/pin-bump.test.js b/test/pin-bump.test.js
nnew file mode 100644
iindex 0000000..31132fb
---- /dev/null
++++ b/test/pin-bump.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++// scripts/pin-bump.js (plan T1): which package.json files pin a library below a release, the pin URL it writes, the
++// arguments it takes, and that STATUS.json / README follow the new pins through scripts/docs-status.js.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const cp = require('child_process');
++const { stalePins, updateVersionLines, pinUrl, parseArgs } = 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-'));
++const write = (f, v) => { fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); fs.writeFileSync(path.join(dir, f), typeof v === 'string' ? v : JSON.stringify(v, null, 2)); };
++write('package.json', { dependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.110.0'), 'openvibe-sdk': url('OpenVibe.SDK', 'v0.26.0') } });
++write('apps/a/package.json', { dependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.112.0') } });
++write('apps/b/package.json', { dependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.90.0') }, peerDependencies: { 'openvibe-shared': '>=2.0.0' } });
++write('node_modules/x/package.json', { dependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.1.0') } });
++write('STATUS.json', { repository: 'OpenVibers/Example', contracts: 'openvibe-contracts v0.110.0', sdk: 'openvibe-sdk v0.26.0', updated: '2026-01-01' });
++cp.execFileSync('git', ['init', '-q', dir]);
++cp.execFileSync('git', ['-C', dir, 'add', 'package.json', 'apps', 'STATUS.json']);
++
++// Only tracked package.json files count, and only pins below the tag; a current pin and a range are left alone.
++const stale = stalePins(dir, 'openvibe-contracts', 'v0.112.0').map((s) => s.file).sort();
++assert.deepStrictEqual(stale, ['apps/b/package.json', 'package.json'], 'apps/a is current; node_modules is not tracked');
++assert.deepStrictEqual(stalePins(dir, 'openvibe-contracts', 'v0.112.0').find((x) => x.file === 'apps/b/package.json').from, ['v0.90.0']);
++assert.deepStrictEqual(stalePins(dir, 'openvibe-sdk', 'v0.26.0'), [], 'already at the tag');
++assert.deepStrictEqual(stalePins(dir, 'openvibe-shared', 'v2.13.0'), [], 'a peer range is not a pin');
++
++// STATUS.json follows the pins once package.json moves.
++const pkg = path.join(dir, 'package.json');
++fs.writeFileSync(pkg, fs.readFileSync(pkg, 'utf8').replace(url('OpenVibe.Contracts', 'v0.110.0'), pinUrl('openvibe-contracts', 'v0.112.0')));
++assert.deepStrictEqual(updateVersionLines(dir), ['STATUS.json']);
++const status = JSON.parse(fs.readFileSync(path.join(dir, 'STATUS.json'), 'utf8'));
++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.throws(() => parseArgs(['left-pad', 'v1.0.0']), /library must be one of/);
++assert.throws(() => parseArgs(['openvibe-sdk', 'latest']), /tag must look like/);
++
++fs.rmSync(dir, { recursive: true, force: true });
++console.log('pin-bump: all checks passed');