openvibers/openvibe.blog Commits

The network changelog and its patch notes: every OpenVibe site's shipped commits in one feed, published on the blog in batches. The worker reads each service's repository and current release from OpenVibe.Network's registry, takes the commits between the release it saw last and the new one from GitHub's compare API (first line, sha, dates, change size), and when CHANGELOG_BATCH_SIZE have gathered, a large release (CHANGELOG_MAJOR_LINES, a version bump, [major]) has been quiet for 30 minutes, or the oldest waited a week, publishes one 'Patch notes' post on the official blog: highlights first, then each site's commit messages linked to the change, authorship imported from GitHub. An AI-written dev-blog version goes along as a draft for review when OpenVibe.AI is configured. GET /api/v1/changelog?service= (public, CORS *) feeds every site's 'recently shipped'. test/changelog.test.js

OP OpenVibers committed 24 Sept 2026 · 32cb1aa0eb · 6 files, +434 −1


Changes

ddiff --git a/server/app.js b/server/app.js
iindex 1a93171..8391fb7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -68,7 +68,8 @@ function createApp(opts = {}) {
blogs.ensureOfficial();
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts };
++ const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });
++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/changelog.js b/server/changelog.js
nnew file mode 100644
iindex 0000000..1a23355
---- /dev/null
++++ b/server/changelog.js
@@@ -0,0 +1,292 @@
++'use strict';
++/**
++ * The network changelog and its patch notes (every OpenVibe site, one feed, published on the blog).
++ *
++ * Collect (every CHANGELOG_INTERVAL_MS): OpenVibe.Network's registry names each service's GitHub
++ * repository (GET /api/v1/registry/services) and the release it runs now (GET
++ * /api/v1/registry/releases, from each service's /release.json). When a service's release changes,
++ * GitHub's compare API (base = the release seen last, head = the new one) gives the commits that
++ * shipped and the size of the change. Each commit becomes a changelog entry (service, sha, subject,
++ * committed_at, deployed_at); a release whose change is large (CHANGELOG_MAJOR_LINES lines, or a
++ * library version bump like "1.7.0: …", or "[major]" in a message) marks its entries major. A service
++ * seen for the first time only records its release, unless CHANGELOG_SINCE backfills from a date.
++ *
++ * Publish: pending entries (not yet in a post) become one "Patch notes" post on the blog
++ * (CHANGELOG_BLOG, default the official blog) when there are CHANGELOG_BATCH_SIZE of them, or when a
++ * major release is pending and deploys have been quiet for CHANGELOG_QUIET_MS (a burst of deploys
++ * becomes one post), or when the oldest has waited CHANGELOG_MAX_AGE_MS; never sooner than
++ * CHANGELOG_MIN_GAP_MS after the last post unless twice the batch is waiting. The post is the commit
++ * messages people wrote, grouped by site with links to each commit and the highlights first:
++ * authorship "imported" (from GitHub), published at once by the blog's first configured owner, so it
++ * reaches feeds, Search and Community Pulse like any post. With OpenVibe.AI configured and
++ * CHANGELOG_AI_DRAFT on, an AI-written dev-blog version is also filed as a draft for a person to
++ * review (AI text is never published unreviewed).
++ *
++ * Read: GET /api/v1/changelog?service=&limit= (public, CORS *) is what every site's "recently
++ * shipped" shows, with the latest patch notes post to link to.
++ */
++const crypto = require('crypto');
++
++const GITHUB = 'https://api.github.com';
++const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
++
++function ensureSchema(db) {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS changelog_state (
++ service TEXT PRIMARY KEY,
++ release TEXT NOT NULL,
++ head TEXT,
++ checked_at INTEGER NOT NULL
++ );
++ CREATE TABLE IF NOT EXISTS changelog_entries (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ service TEXT NOT NULL,
++ repo TEXT NOT NULL,
++ sha TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ committed_at TEXT,
++ deployed_at TEXT NOT NULL,
++ release TEXT NOT NULL,
++ release_lines INTEGER NOT NULL DEFAULT 0,
++ major INTEGER NOT NULL DEFAULT 0,
++ post_id TEXT,
++ UNIQUE (service, sha)
++ );
++ CREATE INDEX IF NOT EXISTS idx_changelog_pending ON changelog_entries(post_id, deployed_at);
++ CREATE TABLE IF NOT EXISTS changelog_posts (
++ post_id TEXT PRIMARY KEY,
++ blog_id TEXT NOT NULL,
++ entries INTEGER NOT NULL,
++ from_at TEXT,
++ to_at TEXT,
++ reason TEXT NOT NULL,
++ ai_draft_id TEXT,
++ created_at INTEGER NOT NULL
++ );
++ `);
++}
++
++/** The first line of a commit message, cut at a word near `n` characters. */
++function subjectOf(message, n = 240) {
++ const first = String(message || '').split('\n')[0].trim();
++ if (first.length <= n) return first;
++ const cut = first.slice(0, n);
++ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), n - 40)).replace(/[,;:(\s]+$/, '')}…`;
++}
++const isVersionBump = (subject) => /^\d+\.\d+\.\d+\b/.test(subject);
++const isNoise = (subject) => /^Merge (branch|pull request|remote)/i.test(subject);
++
++function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImpl = globalThis.fetch, log = console }) {
++ const c = config.changelog;
++ const db = store.db;
++ ensureSchema(db);
++ const nowMs = () => store.now();
++ const stats = { runs: 0, entries: 0, posts: 0, lastError: null, lastRunAt: null };
++
++ const q = {
++ state: db.prepare('SELECT * FROM changelog_state WHERE service = ?'),
++ putState: db.prepare(`INSERT INTO changelog_state (service, release, head, checked_at) VALUES (@service, @release, @head, @at)
++ ON CONFLICT(service) DO UPDATE SET release = excluded.release, head = excluded.head, checked_at = excluded.checked_at`),
++ insert: db.prepare(`INSERT OR IGNORE INTO changelog_entries (service, repo, sha, subject, committed_at, deployed_at, release, release_lines, major)
++ VALUES (@service, @repo, @sha, @subject, @committed_at, @deployed_at, @release, @release_lines, @major)`),
++ pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),
++ mark: db.prepare('UPDATE changelog_entries SET post_id = ? WHERE id = ? AND post_id IS NULL'),
++ lastPost: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT 1'),
++ putPost: db.prepare(`INSERT INTO changelog_posts (post_id, blog_id, entries, from_at, to_at, reason, ai_draft_id, created_at)
++ VALUES (@post_id, @blog_id, @entries, @from_at, @to_at, @reason, @ai_draft_id, @created_at)`),
++ };
++
++ async function getJson(url, { headers = {}, timeoutMs = 15000 } = {}) {
++ const res = await fetchImpl(url, { headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(timeoutMs) });
++ if (!res.ok) throw Object.assign(new Error(`${url.replace(/\?.*$/, '')} answered ${res.status}`), { status: res.status });
++ return res.json();
++ }
++ const gh = (path) => getJson(`${GITHUB}${path}`, { headers: {
++ Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Blog changelog (+https://openvibe.blog)',
++ ...(c.githubToken ? { Authorization: `Bearer ${c.githubToken}` } : {}),
++ } });
++
++ /** [{ id, name, repo, origin, release, releasedAt }] for services that run a release and name a repository. */
++ async function registry() {
++ const base = String(c.networkUrl).replace(/\/+$/, '');
++ const [svc, rel] = await Promise.all([getJson(`${base}/api/v1/registry/services`), getJson(`${base}/api/v1/registry/releases`)]);
++ const services = new Map((svc.services || svc || []).map((s) => [s.id, s]));
++ const out = [];
++ for (const r of rel.releases || rel || []) {
++ const s = services.get(r.id);
++ if (!s || !s.repository || !/^[0-9a-f]{7,40}$/.test(String(r.release || ''))) continue;
++ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(s.repository)) continue;
++ out.push({ id: r.id, name: s.name || r.id, repo: s.repository, origin: s.publicOrigin || null, release: r.release, releasedAt: r.released_at || null });
++ }
++ return out;
++ }
++
++ function addEntries(svc, commits, { releaseLines = 0, major = false } = {}) {
++ const deployedAt = svc.releasedAt || new Date(nowMs()).toISOString();
++ let n = 0;
++ for (const cm of commits) {
++ const subject = subjectOf(cm.commit && cm.commit.message);
++ if (!subject || isNoise(subject)) continue;
++ const flagged = major || isVersionBump(subject) || /\[major\]/i.test((cm.commit && cm.commit.message) || '');
++ n += q.insert.run({
++ service: svc.id, repo: svc.repo, sha: cm.sha, subject, committed_at: (cm.commit && cm.commit.author && cm.commit.author.date) || null,
++ deployed_at: deployedAt, release: svc.release, release_lines: releaseLines, major: flagged ? 1 : 0,
++ }).changes;
++ }
++ return n;
++ }
++
++ async function collectOne(svc) {
++ const st = q.state.get(svc.id);
++ if (st && st.release === svc.release) return 0;
++ if (!st) {
++ let n = 0;
++ if (c.since) {
++ const list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&since=${encodeURIComponent(c.since)}&per_page=100`);
++ n = addEntries(svc, (Array.isArray(list) ? list : []).slice().reverse());
++ }
++ q.putState.run({ service: svc.id, release: svc.release, head: null, at: nowMs() });
++ return n;
++ }
++ const cmp = await gh(`/repos/${svc.repo}/compare/${st.head || st.release}...${svc.release}`);
++ const commits = Array.isArray(cmp.commits) ? cmp.commits : [];
++ const lines = (Array.isArray(cmp.files) ? cmp.files : []).reduce((a, f) => a + (Number(f.additions) || 0) + (Number(f.deletions) || 0), 0);
++ const n = cmp.status === 'behind' ? 0 : addEntries(svc, commits, { releaseLines: lines, major: lines >= c.majorLines });
++ const head = commits.length ? commits[commits.length - 1].sha : st.head;
++ q.putState.run({ service: svc.id, release: svc.release, head, at: nowMs() });
++ return n;
++ }
++
++ async function collect() {
++ let added = 0;
++ for (const svc of await registry()) {
++ try { added += await collectOne(svc); } catch (err) {
++ stats.lastError = `${svc.id}: ${err.message}`;
++ if (err.status === 403 || err.status === 429) break; // GitHub's rate limit: try again next run
++ }
++ }
++ stats.entries += added;
++ return added;
++ }
++
++ /** { due, reason } for the pending entries. */
++ function due(list, at = nowMs()) {
++ if (!list.length) return { due: false, reason: 'nothing pending' };
++ const last = q.lastPost.get();
++ const sinceLast = last ? at - last.created_at : Infinity;
++ const newest = Math.max(...list.map((e) => Date.parse(e.deployed_at) || 0));
++ const oldest = Math.min(...list.map((e) => Date.parse(e.deployed_at) || at));
++ if (list.length >= c.batchSize * 2) return { due: true, reason: 'batch' };
++ if (sinceLast < c.minGapMs) return { due: false, reason: 'too soon after the last post' };
++ if (list.length >= c.batchSize) return { due: true, reason: 'batch' };
++ if (list.some((e) => e.major) && at - newest >= c.quietMs) return { due: true, reason: 'major' };
++ if (list.length >= 5 && at - oldest >= c.maxAgeMs) return { due: true, reason: 'age' };
++ return { due: false, reason: 'waiting' };
++ }
++
++ function render(list, services) {
++ const byService = new Map();
++ for (const e of list) { if (!byService.has(e.service)) byService.set(e.service, []); byService.get(e.service).push(e); }
++ const order = [...byService.keys()].sort((a, b) => byService.get(b).length - byService.get(a).length || a.localeCompare(b));
++ const name = (id) => (services.get(id) && services.get(id).name) || id;
++ const link = (e) => `[\`${e.sha.slice(0, 7)}\`](https://github.com/${e.repo}/commit/${e.sha})`;
++ // A highlight per major release: its most descriptive (longest) commit subject.
++ const majors = new Map();
++ for (const e of list) if (e.major) { const k = `${e.service}@${e.release}`; if (!majors.has(k) || e.subject.length > majors.get(k).subject.length) majors.set(k, e); }
++ const highlights = [...majors.values()].slice(0, 8);
++ const day = (iso) => new Date(iso).toISOString().slice(0, 10);
++ const from = day(list[0].deployed_at);
++ const to = day(list[list.length - 1].deployed_at);
++ const sites = order.map(name);
++ const lead = highlights[0] ? clean(highlights[0].subject.split(/[:.;(]/)[0], 70) : null;
++ const title = lead && lead.length >= 12 ? `Patch notes: ${lead}` : `Patch notes: ${list.length} changes across ${order.length} ${order.length === 1 ? 'site' : 'sites'}`;
++ const summary = clean(`What shipped on OpenVibe ${from === to ? `on ${from}` : `from ${from} to ${to}`}: ${list.length} ${list.length === 1 ? 'change' : 'changes'} to ${sites.slice(0, 6).join(', ')}${sites.length > 6 ? ` and ${sites.length - 6} more` : ''}.`, 400);
++ const out = [`${summary} Every line below is a commit message from the OpenVibers repositories, linked to the change itself.`, ''];
++ if (highlights.length) {
++ out.push('## Highlights', '');
++ for (const e of highlights) out.push(`- **${name(e.service)}:** ${e.subject} (${link(e)})`);
++ out.push('');
++ }
++ for (const id of order) {
++ const svc = services.get(id);
++ out.push(`## ${name(id)}`, '');
++ if (svc && svc.origin) out.push(`[${svc.origin.replace(/^https?:\/\//, '')}](${svc.origin}) · [repository](https://github.com/${svc.repo})`, '');
++ for (const e of byService.get(id)) out.push(`- ${e.subject} (${link(e)})`);
++ out.push('');
++ }
++ out.push('---', '', 'Patch notes are put together automatically when enough changes have shipped, or when a large feature lands. See every site\'s own updates page for the live list.');
++ return { title, summary, body: out.join('\n'), from: list[0].deployed_at, to: list[list.length - 1].deployed_at, tags: ['patch-notes', ...order.slice(0, 10)] };
++ }
++
++ async function publish(list, reason) {
++ const blog = blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? blogs.ensureOfficial() : null);
++ const owner = config.official.owners.find((s) => /^usr_/.test(s));
++ if (!blog || !owner) { stats.lastError = !blog ? `no blog @${c.blogHandle}` : 'BLOG_OFFICIAL_OWNERS names no owner to publish as'; return null; }
++ const services = new Map((await registry().catch(() => [])).map((s) => [s.id, s]));
++ for (const e of list) if (!services.has(e.service)) services.set(e.service, { id: e.service, name: e.service, repo: e.repo, origin: null });
++ const doc = render(list, services);
++ const viewer = { kind: 'user', subject: owner, staff: false };
++ const { post } = posts.create(viewer, blog, {
++ title: doc.title, summary: doc.summary, body: doc.body, tags: doc.tags.filter((t) => t.length <= 50).slice(0, 20), series: 'Patch notes',
++ authorship: { mode: 'imported', importedFrom: { label: 'Commit messages from the OpenVibers repositories on GitHub', originalAuthor: 'OpenVibers' } },
++ message: `Patch notes from ${list.length} commits (${reason})`,
++ });
++ posts.publish(viewer, posts.get ? posts.get(post.id) || post : post);
++ let aiDraftId = null;
++ if (c.aiDraft && aiDrafts && aiDrafts.enabled) {
++ try {
++ const brief = list.map((e) => `- [${e.service}] ${e.subject}`).join('\n').slice(0, 3900);
++ const d = await aiDrafts.draft(viewer, blog, { topic: `Dev blog: ${doc.title.replace(/^Patch notes: /, '')}`, brief: `Write a friendly developer-blog post about what just shipped on OpenVibe, for its community. Only use these changes:\n${brief}`, tone: 'plain, warm, specific', audience: 'OpenVibe members and contributors' });
++ aiDraftId = d.post.id;
++ } catch (err) { log.warn && log.warn(`[changelog] AI dev-blog draft not made: ${err.message}`); }
++ }
++ store.tx(() => {
++ for (const e of list) q.mark.run(post.id, e.id);
++ q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });
++ });
++ stats.posts++;
++ log.log && log.log(`[changelog] published ${post.id} (${list.length} entries, ${reason})`);
++ return { post, aiDraftId };
++ }
++
++ async function tick() {
++ stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
++ try {
++ await collect();
++ const list = q.pending.all();
++ const d = due(list);
++ if (d.due) return await publish(list.slice(0, c.batchSize * 3), d.reason);
++ return null;
++ } catch (err) { stats.lastError = err.message; log.warn && log.warn(`[changelog] ${err.message}`); return null; }
++ }
++
++ let timer = null;
++ function start() {
++ if (!c.enabled || timer) return;
++ const first = setTimeout(() => { tick().catch(() => {}); }, c.firstDelayMs);
++ if (first.unref) first.unref();
++ timer = setInterval(() => { tick().catch(() => {}); }, c.intervalMs);
++ if (timer.unref) timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ /** The public feed: newest first. */
++ function entries({ service = null, limit = 20 } = {}) {
++ const n = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
++ const rows = service
++ ? db.prepare('SELECT * FROM changelog_entries WHERE service = ? ORDER BY deployed_at DESC, id DESC LIMIT ?').all(String(service), n)
++ : db.prepare('SELECT * FROM changelog_entries ORDER BY deployed_at DESC, id DESC LIMIT ?').all(n);
++ return rows.map((e) => ({
++ service: e.service, sha: e.sha, short: e.sha.slice(0, 7), subject: e.subject, committed_at: e.committed_at, deployed_at: e.deployed_at,
++ major: Boolean(e.major), url: `https://github.com/${e.repo}/commit/${e.sha}`, post_id: e.post_id || null,
++ }));
++ }
++ function latestPost() { return q.lastPost.get() || null; }
++
++ return { tick, collect, due, render, publish, start, stop, entries, latestPost, stats: () => ({ enabled: c.enabled, ...stats, pending: q.pending.all().length }) };
++}
++
++/** A stable id for a batch (tests). */
++function batchKey(list) { return crypto.createHash('sha256').update(list.map((e) => `${e.service}:${e.sha}`).join(',')).digest('hex').slice(0, 16); }
++
++module.exports = { createChangelog, subjectOf, ensureSchema, batchKey };
ddiff --git a/server/config.js b/server/config.js
iindex a0e33d6..e3b57ab 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -34,6 +34,22 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ // The network changelog and its patch notes (server/changelog.js).
++ changelog: {
++ enabled: env.CHANGELOG !== 'off',
++ networkUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ githubToken: env.CHANGELOG_GITHUB_TOKEN || '',
++ blogHandle: String(env.CHANGELOG_BLOG || 'openvibe').toLowerCase(),
++ batchSize: Math.max(5, parseInt(env.CHANGELOG_BATCH_SIZE, 10) || 40),
++ majorLines: Math.max(50, parseInt(env.CHANGELOG_MAJOR_LINES, 10) || 800),
++ quietMs: Math.max(0, parseInt(env.CHANGELOG_QUIET_MS, 10) || 30 * 60 * 1000),
++ maxAgeMs: Math.max(0, parseInt(env.CHANGELOG_MAX_AGE_MS, 10) || 7 * 24 * 3600 * 1000),
++ minGapMs: Math.max(0, parseInt(env.CHANGELOG_MIN_GAP_MS, 10) || 6 * 3600 * 1000),
++ intervalMs: Math.max(60 * 1000, parseInt(env.CHANGELOG_INTERVAL_MS, 10) || 10 * 60 * 1000),
++ firstDelayMs: 60 * 1000,
++ since: env.CHANGELOG_SINCE && !Number.isNaN(Date.parse(env.CHANGELOG_SINCE)) ? new Date(env.CHANGELOG_SINCE).toISOString() : null,
++ aiDraft: env.CHANGELOG_AI_DRAFT !== 'off',
++ },
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
oauth: {
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 18cc1d6..2c7f885 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -182,6 +182,22 @@ function createApi(ctx) {
return { total, posts: rows.map((p) => postDto(p)) };
}));
++ // The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and
++ // the latest patch notes post. Public: every site's "recently shipped" reads it.
++ router.get('/changelog', run((req, res) => {
++ res.set('Access-Control-Allow-Origin', '*');
++ res.set('Cache-Control', 'public, max-age=60');
++ const service = /^[a-z][a-z0-9-]{1,39}$/.test(String(req.query.service || '')) ? String(req.query.service) : null;
++ const latest = ctx.changelog.latestPost();
++ let post = null;
++ if (latest) {
++ const p = posts.get(latest.post_id);
++ const blog = p ? blogs.get(p.blog_id) : null;
++ if (p && blog && p.state === 'published') post = { id: p.id, title: (posts.head(p) || { fields: {} }).fields.title || null, url: publication.abs(publication.postPath(blog, p)), published_at: p.published_at || null, entries: latest.entries };
++ }
++ return { service, entries: ctx.changelog.entries({ service, limit: req.query.limit }), latest_post: post };
++ }));
++
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), jsonBody, run(async (req) => {
const blog = mustBlog(req);
ddiff --git a/server/index.js b/server/index.js
iindex 2c4d92f..8fbc0e7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -17,10 +17,13 @@ const server = app.listen(config.port, config.host, () => {
server.keepAliveTimeout = 65_000;
ctx.outbox.start();
ctx.worker.start();
++// The network changelog and patch notes run where the worker runs (one process).
++if (config.worker.enabled) ctx.changelog.start();
function shutdown(signal) {
console.log(`[Blog] ${signal}: closing`);
ctx.worker.stop();
++ ctx.changelog.stop();
server.close(async () => {
try { await ctx.outbox.stop(); } catch { /* best effort */ }
try { ctx.store.close(); } catch { /* already closed */ }
ddiff --git a/test/changelog.test.js b/test/changelog.test.js
nnew file mode 100644
iindex 0000000..ec675f5
---- /dev/null
++++ b/test/changelog.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * The network changelog: releases seen at Network become entries from GitHub's compare; a batch, a
++ * major release after a quiet spell, or age publishes one "Patch notes" post (imported authorship,
++ * grouped by site, highlights first); the public /api/v1/changelog feeds every site.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createChangelog, subjectOf } = require('../server/changelog');
++
++const H = (n) => n.toString(16).padStart(40, 'a');
++(async () => {
++ const t = await boot();
++ const releases = { live: 'aaaaaaa00001', tools: 'bbbbbbb00001' };
++ const compares = {};
++ const ghCalls = [];
++ const fetchImpl = async (url) => {
++ const u = new URL(url);
++ const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
++ if (u.pathname === '/api/v1/registry/services') return json({ services: [
++ { id: 'live', name: 'OpenVibe.Live', repository: 'OpenVibers/OpenVibe.Live', publicOrigin: 'https://openvibe.live' },
++ { id: 'tools', name: 'OpenVibe.Tools', repository: 'OpenVibers/OpenVibe.Tools', publicOrigin: 'https://openvibe.tools' },
++ { id: 'norepo', name: 'X' },
++ ] });
++ if (u.pathname === '/api/v1/registry/releases') return json({ releases: Object.entries(releases).map(([id, r]) => ({ id, release: r, released_at: new Date(t.clock.now()).toISOString() })) });
++ if (u.hostname === 'api.github.com') {
++ ghCalls.push(u.pathname);
++ const m = u.pathname.match(/^\/repos\/(.+)\/compare\/(.+)\.\.\.(.+)$/);
++ if (m && compares[m[3]]) return json(compares[m[3]]);
++ return json({ message: 'Not Found' }, 404);
++ }
++ return json({}, 404);
++ };
++ const config = { ...t.ctx.config, changelog: { ...t.ctx.config.changelog, enabled: true, batchSize: 5, majorLines: 500, quietMs: 30 * 60 * 1000, maxAgeMs: 7 * 24 * 3600 * 1000, minGapMs: 6 * 3600 * 1000, since: null, aiDraft: false, blogHandle: 'openvibe' } };
++ const cl = createChangelog({ config, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl, log: { log() {}, warn() {} } });
++ const commit = (i, msg) => ({ sha: H(i), commit: { message: msg, author: { date: new Date(t.clock.now()).toISOString() } } });
++
++ await check('a service seen for the first time only records its release', async () => {
++ await cl.tick();
++ assert.strictEqual(cl.entries().length, 0);
++ assert.deepStrictEqual(ghCalls, [], 'no history dump');
++ });
++
++ await check('a new release adds its commits; small ones wait for a batch', async () => {
++ releases.live = 'aaaaaaa00002';
++ compares.aaaaaaa00002 = { status: 'ahead', commits: [commit(1, 'Fix the chat scroll\n\nbody text'), commit(2, 'Merge branch main')], files: [{ additions: 10, deletions: 2 }] };
++ const r = await cl.tick();
++ assert.strictEqual(r, null, 'not due yet');
++ const e = cl.entries({ service: 'live' });
++ assert.deepStrictEqual(e.map((x) => x.subject), ['Fix the chat scroll'], 'first lines only; merges skipped');
++ assert.strictEqual(e[0].url, `https://github.com/OpenVibers/OpenVibe.Live/commit/${H(1)}`);
++ assert.strictEqual(e[0].major, false);
++ });
++
++ await check('a major release publishes patch notes after a quiet spell', async () => {
++ releases.tools = 'bbbbbbb00002';
++ compares.bbbbbbb00002 = { status: 'ahead', commits: [commit(3, 'Recent tools in the launcher: anonymous and account history'), commit(4, 'Tidy the docs')], files: [{ additions: 700, deletions: 20 }] };
++ assert.strictEqual(await cl.tick(), null, 'major, but deploys are not quiet yet');
++ t.clock.advance(31 * 60 * 1000);
++ const r = await cl.tick();
++ assert.ok(r && r.post, 'published');
++ const post = t.ctx.posts.get(r.post.id);
++ assert.strictEqual(post.state, 'published');
++ const head = t.ctx.posts.head(post);
++ assert.strictEqual(head.fields.title, 'Patch notes: Recent tools in the launcher');
++ assert.ok(head.content.includes('## Highlights') && head.content.includes('**OpenVibe.Tools:** Recent tools in the launcher'));
++ assert.ok(head.content.includes('## OpenVibe.Live') && head.content.includes('Fix the chat scroll'));
++ assert.strictEqual(head.meta.authorship.mode, 'imported');
++ assert.strictEqual(cl.entries().filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');
++ });
++
++ await check('the public feed names the entries and the latest post', async () => {
++ const r = await t.get('/api/v1/changelog?service=tools&limit=5');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ const j = r.json();
++ assert.deepStrictEqual(j.entries.map((x) => x.subject).sort(), ['Recent tools in the launcher: anonymous and account history', 'Tidy the docs']);
++ assert.ok(j.entries.every((x) => x.service === 'tools' && x.post_id));
++ assert.ok(j.latest_post && /\/@openvibe\//.test(j.latest_post.url), 'the latest patch notes post, to link to');
++ assert.strictEqual(j.latest_post.title, 'Patch notes: Recent tools in the launcher');
++ });
++
++ await check('a batch publishes without a major, but never sooner than the gap after the last post', async () => {
++ releases.live = 'aaaaaaa00003';
++ compares.aaaaaaa00003 = { status: 'ahead', commits: [5, 6, 7, 8, 9].map((i) => commit(i, `Small change ${i}`)), files: [{ additions: 5, deletions: 5 }] };
++ assert.strictEqual(await cl.tick(), null, 'too soon after the last post');
++ t.clock.advance(6 * 3600 * 1000 + 1000);
++ const r = await cl.tick();
++ assert.ok(r && r.post);
++ assert.ok(t.ctx.posts.head(t.ctx.posts.get(r.post.id)).fields.title.startsWith('Patch notes: 5 changes across 1 site'));
++ });
++
++ await check('subjects are cut at a word; version bumps count as major', async () => {
++ assert.strictEqual(subjectOf('short'), 'short');
++ const long = subjectOf(`${'word '.repeat(80)}end`, 60);
++ assert.ok(long.endsWith('…') && long.length <= 61);
++ releases.tools = 'bbbbbbb00003';
++ compares.bbbbbbb00003 = { status: 'ahead', commits: [commit(10, '1.7.0: openvibe-shared/trace')], files: [{ additions: 3, deletions: 1 }] };
++ await cl.tick();
++ assert.strictEqual(cl.entries({ service: 'tools' })[0].major, true);
++ });
++
++ await t.close();
++ done();
++})();