feat(updates): "What's New" highlights after update + pre-update teaser

Surfaces release highlights to admins, sourced from the GitHub release notes
(no AI at runtime). Bullets are written once per release in CI via GitHub Models
(see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app
reads that block and falls back to the changelog's "### Features" for releases
without it — so it works against today's releases immediately.

- backend utils/whatsNew.parseWhatsNew(body): curated block else Features
  section, strips scope/PR-links, de-dups, caps at 8 (tested).
- GET  /admin/system/updates/whatsnew: highlights for every version moved
  through since the per-instance marker (whatsnew_last_seen_version); fresh
  installs self-anchor silently. Best-effort, never errors.
- POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance).
- /admin/system/updates also returns latestHighlights for the teaser.
- Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on
  the dashboard via adminService; UpdateNotification shows a "New features
  include:" teaser. i18n de/en. No migration (uses app_settings).
This commit is contained in:
Luca
2026-06-30 02:31:13 +02:00
parent a24821de55
commit 500cf8522e
10 changed files with 463 additions and 2 deletions
+54
View File
@@ -0,0 +1,54 @@
const { parseWhatsNew } = require('../../src/utils/whatsNew');
describe('parseWhatsNew', () => {
it('prefers the curated <!-- whatsnew --> block', () => {
const body = [
'<!-- whatsnew -->',
'- Invoice drafts in list',
'- Bank transfer payments',
'<!-- /whatsnew -->',
'',
'### Features',
'* **invoices:** something long that should be ignored ([#1](http://x))',
].join('\n');
expect(parseWhatsNew(body)).toEqual(['Invoice drafts in list', 'Bank transfer payments']);
});
it('falls back to the Features section, stripping scope + commit links', () => {
const body = [
'## [3.73.0-beta.0](http://x) (2026-06-29)',
'',
'### Features',
'',
'* **dashboard:** revenue tile toggles 365 days ([d1c9e02](http://c))',
'* **invoices:** surface monthly drafts in the Bills list ([e457656](http://c))',
'',
'### Bug Fixes',
'',
'* **invoices:** add bank transfer ([e96ef4c](http://c))',
].join('\n');
expect(parseWhatsNew(body)).toEqual([
'revenue tile toggles 365 days',
'surface monthly drafts in the Bills list',
]);
});
it('excludes Bug Fixes from the fallback', () => {
const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one';
expect(parseWhatsNew(body)).toEqual(['feature one']);
});
it('caps at 8 bullets and de-dups', () => {
const lines = Array.from({ length: 12 }, (_, i) => `- bullet ${i % 9}`);
const body = `<!-- whatsnew -->\n${lines.join('\n')}\n<!-- /whatsnew -->`;
const out = parseWhatsNew(body);
expect(out.length).toBe(8);
expect(new Set(out).size).toBe(8);
});
it('returns [] for empty / non-string input', () => {
expect(parseWhatsNew('')).toEqual([]);
expect(parseWhatsNew(null)).toEqual([]);
expect(parseWhatsNew(undefined)).toEqual([]);
});
});
+78 -2
View File
@@ -7,7 +7,9 @@ const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel, getReleasesSince } = require('../services/updateCheckService');
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
const { parseWhatsNew } = require('../utils/whatsNew');
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
const {
checkAndNotifyUpdates,
@@ -61,9 +63,20 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
const forceRefresh = req.query.refresh === 'true';
const updateInfo = await checkForUpdates(forceRefresh);
// Pre-update teaser: the target version's top highlights, so the
// "Update Available" banner can show "New features include …".
let latestHighlights = [];
if (updateInfo.updateAvailable) {
try {
const newer = await getReleasesSince(updateInfo.current, updateInfo.channel);
if (newer[0]) latestHighlights = parseWhatsNew(newer[0].body);
} catch (_) { /* teaser is best-effort */ }
}
res.json({
enabled: true,
...updateInfo
...updateInfo,
latestHighlights
});
} catch (error) {
logger.error('Error checking for updates:', error);
@@ -71,6 +84,69 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
}
});
// What's New — after-update highlights. Returns the curated bullets for
// every release the instance moved THROUGH since it last acknowledged one
// (lastSeen < version <= running). Seen-tracking is per-INSTANCE: the first
// admin to dismiss clears it for everyone (a single app_settings row). A
// brand-new install initialises the marker silently so it never pops
// "what's new" with nothing to compare against. Best-effort: any failure
// (GitHub unreachable, etc.) returns hasNews:false, never errors.
router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
if (process.env.UPDATE_CHECK_ENABLED === 'false') {
return res.json({ enabled: false, hasNews: false });
}
const running = await getCurrentVersion();
const channel = getCurrentChannel(running);
const lastSeen = await getAppSetting('whatsnew_last_seen_version', null);
if (!lastSeen) {
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
return res.json({ enabled: true, hasNews: false, running });
}
if (compareVersions(running, lastSeen) <= 0) {
return res.json({ enabled: true, hasNews: false, running });
}
// Releases in (lastSeen, running], newest-first, with their highlights.
const releases = (await getReleasesSince(lastSeen, channel))
.filter((r) => compareVersions(r.version, running) <= 0);
const versions = releases
.map((r) => ({
version: r.version,
name: r.name,
publishedAt: r.publishedAt,
htmlUrl: r.htmlUrl,
bullets: parseWhatsNew(r.body),
}))
.filter((v) => v.bullets.length > 0);
return res.json({
enabled: true,
hasNews: versions.length > 0,
fromVersion: lastSeen,
toVersion: running,
versions,
});
} catch (error) {
logger.error('Error building what\'s-new:', error);
res.json({ enabled: true, hasNews: false });
}
});
// Acknowledge the What's New — advance the per-instance marker to the
// running version so it stops showing for every admin.
router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const running = await getCurrentVersion();
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
res.json({ ok: true, lastSeen: running });
} catch (error) {
logger.error('Error marking what\'s-new seen:', error);
res.status(500).json({ error: 'Failed to update marker' });
}
});
// Aggregated changelog — every release between current and latest in
// the user's channel. Powers the update-available modal (#567) so the
// admin can read release notes for ALL versions they're behind on, not
+68
View File
@@ -0,0 +1,68 @@
'use strict';
/**
* Extract user-facing "What's New" bullets from a GitHub release body.
*
* Prefers the curated block the release CI injects via GitHub Models:
* <!-- whatsnew -->\n- bullet\n- bullet\n<!-- /whatsnew -->
* and falls back to the release-please "### Features" section for releases
* created before that CI step existed — so the feature works against
* today's releases too (just with longer, auto-cleaned lines).
*
* Returns at most MAX_BULLETS cleaned strings.
*/
const MAX_BULLETS = 8;
/** Strip list markers, conventional-commit scope, and trailing PR/sha links. */
function cleanBullet(line) {
return line
.replace(/^\s*[-*]\s+/, '') // "- " / "* " marker
.replace(/^\*\*([^:*]+):\*\*\s*/, '') // "**scope:** " prefix
.replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // " ([#41](url))" / " ([sha](url))"
.replace(/\s*\(#\d+\)/g, '') // bare " (#41)"
.replace(/`/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/** Pull the lines under a "## Features" / "### Features" heading. */
function extractFeaturesSection(body) {
const out = [];
let inFeatures = false;
for (const raw of body.split('\n')) {
const line = raw.trimEnd();
if (/^#{2,4}\s+Features\b/i.test(line)) { inFeatures = true; continue; }
if (inFeatures && /^#{1,4}\s+\S/.test(line)) break; // next heading ends the section
if (inFeatures) out.push(line);
}
return out.join('\n');
}
/**
* @param {string} body release notes markdown
* @returns {string[]} up to 8 user-facing bullet strings
*/
function parseWhatsNew(body) {
if (!body || typeof body !== 'string') return [];
const block = body.match(/<!--\s*whatsnew\s*-->([\s\S]*?)<!--\s*\/whatsnew\s*-->/i);
const src = block ? block[1] : extractFeaturesSection(body);
const bullets = src
.split('\n')
.map((l) => l.trim())
.filter((l) => /^[-*]\s+/.test(l))
.map(cleanBullet)
.filter((l) => l.length > 0);
// De-dup while preserving order, then cap.
const seen = new Set();
const unique = [];
for (const b of bullets) {
const key = b.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
unique.push(b);
}
return unique.slice(0, MAX_BULLETS);
}
module.exports = { parseWhatsNew };