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)
Changes
@@@ -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.@@@ -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')`,nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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');
@@ -6,6 +6,11 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,11 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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.
- `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.
@@ -23,6 +23,7 @@ were reconciled first.
@@ -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()` |
| 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`) |
| 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` |
| 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) |
| 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')`,
Every module file has an explicit `exports` entry (no wildcards), so `require('openvibe-shared/navbar')`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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');