chore(changelog): retire CHANGELOG.md as a dependency
The update dialog (server and desktop) now reads changelog/index.json and renders title, intro and groups; the release workflow builds the GitHub Release body and name from changelog/<version>.md; oc-dev, the issue-intake agent, AGENTS.md and the changelog skill no longer point at the file. CHANGELOG.md stays as a legacy copy for installs up to 1.22.1, which fetch it for update notes. The generator refreshes it while it exists and never recreates it, so deleting it after 2026-09-19 retires it for good. Generated outputs hold released versions only, so editing unreleased.md never makes them stale: agents write that file and nothing else, and oc-dev create-release does the generation. Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// Release notes for the update dialog.
|
||||
//
|
||||
// The repo publishes `changelog/index.json` on `main`: one object per release,
|
||||
// newest first, with the app notes grouped as New / Improvements / Fixes /
|
||||
// Misc. This module fetches it and renders the releases between the installed
|
||||
// version (exclusive) and the offered one (inclusive) as the Markdown the
|
||||
// dialog already understands: a `## [x.y.z] - YYYY-MM-DD` header per release,
|
||||
// the release title in bold, the intro, then the groups.
|
||||
//
|
||||
// Any failure (network, 404, unexpected shape) yields null: the update is
|
||||
// still offered, only without notes.
|
||||
|
||||
export const CHANGELOG_INDEX_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/changelog/index.json';
|
||||
|
||||
const GROUPS = [
|
||||
['new', 'New'],
|
||||
['improvements', 'Improvements'],
|
||||
['fixes', 'Fixes'],
|
||||
['misc', 'Misc'],
|
||||
];
|
||||
|
||||
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// The file crosses a network boundary with no schema library on this side, so
|
||||
// each field is coerced into its domain shape here; a release without a valid
|
||||
// version and date is dropped.
|
||||
const text = (value) => (value === null || value === undefined ? null : String(value).trim() || null);
|
||||
const bulletList = (value) => (Array.isArray(value) ? value.map((item) => String(item)) : []);
|
||||
|
||||
const parseRelease = (entry) => {
|
||||
if (entry === null || entry === undefined) return null;
|
||||
const version = text(entry.version);
|
||||
const date = text(entry.date);
|
||||
if (!version || !VERSION_PATTERN.test(version) || !date || !DATE_PATTERN.test(date)) return null;
|
||||
const app = entry.app ?? {};
|
||||
return {
|
||||
version,
|
||||
date,
|
||||
title: text(entry.title),
|
||||
intro: text(entry.intro),
|
||||
groups: GROUPS.map(([key, heading]) => ({ heading, bullets: bulletList(app[key]) })),
|
||||
};
|
||||
};
|
||||
|
||||
const renderRelease = (release) => {
|
||||
const lines = [`## [${release.version}] - ${release.date}`, ''];
|
||||
if (release.title) lines.push(`**${release.title}**`, '');
|
||||
if (release.intro) lines.push(release.intro, '');
|
||||
for (const { heading, bullets } of release.groups) {
|
||||
if (bullets.length === 0) continue;
|
||||
lines.push(`### ${heading}`, '', ...bullets.map((bullet) => `- ${bullet}`), '');
|
||||
}
|
||||
return lines.join('\n').trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown for the releases in (fromVersion, toVersion], newest first, or
|
||||
* null when the index holds none of them.
|
||||
*/
|
||||
export function renderUpdateNotes(index, fromVersion, toVersion, compareVersions) {
|
||||
if (!Array.isArray(index)) return null;
|
||||
const relevant = index
|
||||
.map(parseRelease)
|
||||
.filter((release) => release !== null)
|
||||
.filter((release) => compareVersions(release.version, fromVersion) > 0 && compareVersions(release.version, toVersion) <= 0)
|
||||
.sort((a, b) => compareVersions(b.version, a.version));
|
||||
if (relevant.length === 0) return null;
|
||||
return relevant.map(renderRelease).join('\n\n');
|
||||
}
|
||||
|
||||
/** Fetch the index and render the notes for one update; null on any failure. */
|
||||
export async function fetchUpdateNotes(fromVersion, toVersion, compareVersions, options = {}) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
try {
|
||||
const response = await fetchImpl(CHANGELOG_INDEX_URL, { signal: AbortSignal.timeout(options.timeoutMs ?? 10_000) });
|
||||
if (!response.ok) return null;
|
||||
return renderUpdateNotes(await response.json(), fromVersion, toVersion, compareVersions);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fetchUpdateNotes, renderUpdateNotes } from './update-notes.js';
|
||||
|
||||
const compare = (a, b) => {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i += 1) if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
||||
return 0;
|
||||
};
|
||||
|
||||
const index = [
|
||||
{ version: '1.2.3', date: '2026-03-03', title: 'Comments everywhere', intro: 'A short intro.', app: { new: ['**Comments:** on code.'], improvements: [], fixes: ['Chat: no freeze.'], misc: [] }, vscode: null },
|
||||
{ version: '1.2.2', date: '2026-03-02', title: null, intro: null, app: { new: [], improvements: ['Faster.'], fixes: [], misc: [] }, vscode: null },
|
||||
{ version: '1.2.1', date: '2026-03-01', title: 'Old', intro: null, app: { new: ['Older.'], improvements: [], fixes: [], misc: [] }, vscode: null },
|
||||
];
|
||||
|
||||
describe('renderUpdateNotes', () => {
|
||||
it('renders the releases after the installed version up to the offered one, newest first', () => {
|
||||
expect(renderUpdateNotes(index, '1.2.1', '1.2.3', compare)).toBe(`## [1.2.3] - 2026-03-03
|
||||
|
||||
**Comments everywhere**
|
||||
|
||||
A short intro.
|
||||
|
||||
### New
|
||||
|
||||
- **Comments:** on code.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Chat: no freeze.
|
||||
|
||||
## [1.2.2] - 2026-03-02
|
||||
|
||||
### Improvements
|
||||
|
||||
- Faster.`);
|
||||
});
|
||||
|
||||
it('returns null when nothing lies in the range or the payload is not an index', () => {
|
||||
expect(renderUpdateNotes(index, '1.2.3', '1.2.3', compare)).toBe(null);
|
||||
expect(renderUpdateNotes({ entries: [] }, '1.0.0', '9.9.9', compare)).toBe(null);
|
||||
expect(renderUpdateNotes([{ version: 'nope' }, { version: '1.2.2', date: '2026' }], '1.0.0', '9.9.9', compare)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchUpdateNotes', () => {
|
||||
it('turns a failed request or a thrown fetch into null', async () => {
|
||||
const notFound = async () => ({ ok: false });
|
||||
expect(await fetchUpdateNotes('1.0.0', '2.0.0', compare, { fetch: notFound })).toBe(null);
|
||||
const throwing = async () => { throw new Error('offline'); };
|
||||
expect(await fetchUpdateNotes('1.0.0', '2.0.0', compare, { fetch: throwing })).toBe(null);
|
||||
});
|
||||
|
||||
it('renders a successful response', async () => {
|
||||
const ok = async () => ({ ok: true, json: async () => index });
|
||||
expect(await fetchUpdateNotes('1.2.2', '1.2.3', compare, { fetch: ok })).toMatch(/^## \[1\.2\.3\] - 2026-03-03\n\n\*\*Comments everywhere\*\*/);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { fetchUpdateNotes } from './changelog/update-notes.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -11,7 +12,6 @@ const __dirname = path.dirname(__filename);
|
||||
const PACKAGE_NAME = '@openchamber/web';
|
||||
const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/');
|
||||
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md';
|
||||
const GITHUB_RELEASES_URL = 'https://github.com/openchamber/openchamber/releases';
|
||||
const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/openchamber/openchamber/releases';
|
||||
let cachedDetectedPm = null;
|
||||
@@ -737,34 +737,9 @@ function compareVersions(left, right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch changelog notes between versions
|
||||
*/
|
||||
/** Release notes between the installed and the offered version, or undefined. */
|
||||
async function fetchChangelogNotes(fromVersion, toVersion) {
|
||||
try {
|
||||
const response = await fetch(CHANGELOG_URL, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const changelog = await response.text();
|
||||
const sections = changelog.split(/^## /m).slice(1);
|
||||
|
||||
const relevantSections = sections.filter((section) => {
|
||||
const match = section.match(/^\[(\d+\.\d+\.\d+)\]/);
|
||||
if (!match) return false;
|
||||
return compareVersions(match[1], fromVersion) > 0 && compareVersions(match[1], toVersion) <= 0;
|
||||
});
|
||||
|
||||
if (relevantSections.length === 0) return undefined;
|
||||
|
||||
return relevantSections
|
||||
.map((s) => '## ' + s.trim())
|
||||
.join('\n\n');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return (await fetchUpdateNotes(fromVersion, toVersion, compareVersions)) ?? undefined;
|
||||
}
|
||||
|
||||
export async function checkForUpdates(options = {}) {
|
||||
|
||||
Reference in New Issue
Block a user