feat: rework release notes around GitHub releases (#981)

* feat: rework release notes around GitHub releases

- switch /release-notes from upstream changelog parsing to OpenClaude GitHub release data
- add a public build version helper and use it for release URL/seen-version tracking
- render release notes in-app with section headers like Features and Bug Fixes
- cache serialized GitHub release notes locally for startup and command fallback paths
- preserve snake_case identifiers while sanitizing markdown content
- keep LogoV2 whats-new output within its display budget
- add focused tests for parsing, formatting, version tags, and display slicing

* Normalize release-please changelog versions

Use normalizePublicVersion when parsing cached changelog headings so release-please markdown headings like ## [0.8.0](...) (2026-05-02) map to the expected version key.

Also normalize direct version lookups consistently and add a regression test covering getReleaseNotesForVersion and getRecentReleaseNotes against the new CHANGELOG.md format.
This commit is contained in:
JATMN
2026-05-02 05:26:58 -07:00
committed by GitHub
parent 35f86a9580
commit d948769dd5
7 changed files with 503 additions and 77 deletions
+31 -29
View File
@@ -1,50 +1,52 @@
import type { LocalCommandResult } from '../../types/command.js'
import {
CHANGELOG_URL,
fetchReleaseNotesForVersion,
fetchAndStoreChangelog,
getAllReleaseNotes,
formatReleaseNotesForDisplay,
getReleaseNotesForVersion,
getStoredChangelog,
} from '../../utils/releaseNotes.js'
import { getReleaseTagUrl, publicBuildVersion } from '../../utils/version.js'
function formatReleaseNotes(notes: Array<[string, string[]]>): string {
return notes
.map(([version, notes]) => {
const header = `Version ${version}:`
const bulletPoints = notes.map(note => `· ${note}`).join('\n')
return `${header}\n${bulletPoints}`
})
.join('\n\n')
}
export async function call(): Promise<LocalCommandResult> {
// Try to fetch the latest changelog with a 500ms timeout
let freshNotes: Array<[string, string[]]> = []
async function getCurrentReleaseNotes(): Promise<string[]> {
try {
const freshNotes = await fetchReleaseNotesForVersion(publicBuildVersion)
if (freshNotes.length > 0) {
return freshNotes
}
} catch {
// Fall back to cached notes below.
}
try {
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(rej => rej(new Error('Timeout')), 500, reject)
setTimeout(rej => rej(new Error('Timeout')), 1500, reject)
})
await Promise.race([fetchAndStoreChangelog(), timeoutPromise])
freshNotes = getAllReleaseNotes(await getStoredChangelog())
} catch {
// Either fetch failed or timed out - just use cached notes
// Fall back to cached notes below.
}
// If we have fresh notes from the quick fetch, use those
if (freshNotes.length > 0) {
return { type: 'text', value: formatReleaseNotes(freshNotes) }
return getReleaseNotesForVersion(
publicBuildVersion,
await getStoredChangelog(),
)
}
export async function call(): Promise<LocalCommandResult> {
const url = getReleaseTagUrl(publicBuildVersion)
const notes = await getCurrentReleaseNotes()
if (notes.length > 0) {
return {
type: 'text',
value: `Release notes for ${publicBuildVersion}:\n${formatReleaseNotesForDisplay(notes)}\n\nFull release page: ${url}`,
}
}
// Otherwise check cached notes
const cachedNotes = getAllReleaseNotes(await getStoredChangelog())
if (cachedNotes.length > 0) {
return { type: 'text', value: formatReleaseNotes(cachedNotes) }
}
// Nothing available, show link
return {
type: 'text',
value: `See the full changelog at: ${CHANGELOG_URL}`,
value: `Release notes: ${url}`,
}
}
+4 -3
View File
@@ -19,6 +19,7 @@ import { getSteps, shouldShowProjectOnboarding, incrementProjectOnboardingSeenCo
import { CondensedLogo } from './CondensedLogo.js';
import { OffscreenFreeze } from '../OffscreenFreeze.js';
import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js';
import { publicBuildVersion } from '../../utils/version.js';
import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js';
import { isEnvTruthy } from 'src/utils/envUtils.js';
import { getStartupPerfLogPath, isDetailedProfilingEnabled } from 'src/utils/startupProfiler.js';
@@ -94,7 +95,7 @@ export function LogoV2() {
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = () => {
const currentConfig = getGlobalConfig();
if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) {
if (currentConfig.lastReleaseNotesSeen === publicBuildVersion) {
return;
}
saveGlobalConfig(_temp3);
@@ -528,12 +529,12 @@ export function LogoV2() {
return t41;
}
function _temp3(current) {
if (current.lastReleaseNotesSeen === MACRO.VERSION) {
if (current.lastReleaseNotesSeen === publicBuildVersion) {
return current;
}
return {
...current,
lastReleaseNotesSeen: MACRO.VERSION
lastReleaseNotesSeen: publicBuildVersion
};
}
function _temp2(s_0) {
+6
View File
@@ -7,6 +7,7 @@ import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/
import type { LogOption } from '../../types/logs.js';
import { getCwd } from '../../utils/cwd.js';
import { formatRelativeTimeAgo } from '../../utils/format.js';
import { getReleaseSectionHeaderTitle, isReleaseSectionHeader } from '../../utils/releaseNotes.js';
import type { FeedConfig, FeedLine } from './Feed.js';
export function createRecentActivityFeed(activities: LogOption[]): FeedConfig {
const lines: FeedLine[] = activities.map(log => {
@@ -35,6 +36,11 @@ export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
};
}
}
if (isReleaseSectionHeader(note)) {
return {
text: `${getReleaseSectionHeaderTitle(note)}:`
};
}
return {
text: note
};
+6 -3
View File
@@ -9,7 +9,11 @@ import {
truncateToWidth,
truncateToWidthNoEllipsis,
} from './format.js'
import { getStoredChangelogFromMemory, parseChangelog } from './releaseNotes.js'
import {
getStoredChangelogFromMemory,
parseChangelog,
sliceReleaseNotesForDisplay,
} from './releaseNotes.js'
import { gt } from './semver.js'
import { loadMessageLogs } from './sessionStorage.js'
import { getInitialSettings } from './settings/settings.js'
@@ -345,6 +349,5 @@ export function getRecentReleaseNotesSync(maxItems: number): string[] {
}
}
// Return raw notes without filtering or premature truncation
return allNotes.slice(0, maxItems)
return sliceReleaseNotesForDisplay(allNotes, maxItems)
}
+121
View File
@@ -0,0 +1,121 @@
import { expect, test } from 'bun:test'
import {
formatReleaseNotesForDisplay,
getRecentReleaseNotes,
getReleaseNotesForVersion,
parseGitHubReleaseBody,
sliceReleaseNotesForDisplay,
serializeGitHubReleasesAsChangelog,
} from './releaseNotes.js'
import { getReleaseTagUrl } from './version.js'
test('parseGitHubReleaseBody strips markdown links and trailing refs', () => {
expect(
parseGitHubReleaseBody(`### Features
* add thing ([#1](https://example.com)) ([abc1234](https://example.com))
### Bug Fixes
* **api:** fix bug`),
).toEqual([
'__section__:Features',
'add thing',
'__section__:Bug Fixes',
'api: fix bug',
])
})
test('parseGitHubReleaseBody preserves snake_case identifiers', () => {
expect(
parseGitHubReleaseBody(
'* add OPENCLAUDE_DISABLE_TOOL_REMINDERS env var to suppress reminders',
),
).toEqual([
'add OPENCLAUDE_DISABLE_TOOL_REMINDERS env var to suppress reminders',
])
})
test('serializeGitHubReleasesAsChangelog keeps versioned notes accessible', () => {
const changelog = serializeGitHubReleasesAsChangelog([
{
tag_name: 'v0.8.0',
body: `* add thing ([#1](https://example.com)) ([abc1234](https://example.com))
* fix another thing`,
},
])
expect(getReleaseNotesForVersion('0.8.0', changelog)).toEqual([
'add thing',
'fix another thing',
])
})
test('getRecentReleaseNotes treats legacy internal seen versions as unseen', () => {
expect(
getRecentReleaseNotes('0.8.0', '99.0.0', '## 0.8.0\n- latest change'),
).toEqual(['latest change'])
})
test('release-please changelog headings are normalized for version lookups', () => {
const changelog = `# Changelog
## [0.8.0](https://github.com/Gitlawb/openclaude/compare/v0.7.0...v0.8.0) (2026-05-02)
### Features
* add thing
## [0.7.0](https://github.com/Gitlawb/openclaude/compare/v0.6.0...v0.7.0) (2026-04-26)
### Bug Fixes
* fix thing`
expect(getReleaseNotesForVersion('0.8.0', changelog)).toEqual(['add thing'])
expect(getRecentReleaseNotes('0.8.0', '0.7.0', changelog)).toEqual([
'add thing',
])
})
test('getReleaseTagUrl normalizes build metadata to the public tag', () => {
expect(getReleaseTagUrl('0.8.0+abc123')).toBe(
'https://github.com/Gitlawb/openclaude/releases/tag/v0.8.0',
)
})
test('formatReleaseNotesForDisplay renders section headers and bullets', () => {
expect(
formatReleaseNotesForDisplay([
'__section__:Features',
'add thing',
'__section__:Bug Fixes',
'fix bug',
]),
).toBe('Features:\n- add thing\n\nBug Fixes:\n- fix bug')
})
test('sliceReleaseNotesForDisplay preserves headers without counting them', () => {
expect(
sliceReleaseNotesForDisplay(
[
'__section__:Features',
'add thing',
'__section__:Bug Fixes',
'fix bug',
],
1,
),
).toEqual([])
})
test('sliceReleaseNotesForDisplay keeps total rendered lines within budget', () => {
expect(
sliceReleaseNotesForDisplay(
[
'__section__:Features',
'add thing',
'__section__:Bug Fixes',
'fix bug',
],
3,
),
).toEqual(['__section__:Features', 'add thing'])
})
+279 -42
View File
@@ -9,26 +9,39 @@ import { toError } from './errors.js'
import { logError } from './log.js'
import { isEssentialTrafficOnly } from './privacyLevel.js'
import { gt } from './semver.js'
import {
normalizePublicVersion,
OPENCLAUDE_RELEASES_URL,
publicBuildVersion,
} from './version.js'
const MAX_RELEASE_NOTES_SHOWN = 5
const RELEASES_API_URL =
'https://api.github.com/repos/Gitlawb/openclaude/releases?per_page=10'
const SECTION_HEADER_PREFIX = '__section__:'
type GitHubRelease = {
body?: string | null
draft?: boolean
prerelease?: boolean
tag_name?: string | null
}
/**
* We fetch the changelog from GitHub instead of bundling it with the build.
* We fetch OpenClaude release notes from GitHub instead of bundling them with
* the build.
*
* This is necessary because Ink's static rendering makes it difficult to
* dynamically update/show components after initial render. By storing the
* changelog in config, we ensure it's available on the next startup without
* requiring a full re-render of the current UI.
* fetched notes in config, we ensure they're available on the next startup
* without requiring a full re-render of the current UI.
*
* The flow is:
* 1. User updates to a new version
* 2. We fetch the changelog in the background and store it in config
* 3. Next time the user starts Claude, the cached changelog is available immediately
* 2. We fetch GitHub release notes in the background and store them in config
* 3. Next startup, the cached release notes are available immediately
*/
export const CHANGELOG_URL =
'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md'
const RAW_CHANGELOG_URL =
'https://raw.githubusercontent.com/anthropics/claude-code/refs/heads/main/CHANGELOG.md'
export const RELEASES_URL = OPENCLAUDE_RELEASES_URL
/**
* Get the path for the cached changelog file.
@@ -47,6 +60,156 @@ export function _resetChangelogCacheForTesting(): void {
changelogMemoryCache = null
}
function sanitizeReleaseNote(note: string): string {
let sanitized = note
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/(^|[\s([{])_([^_\s][^_]*?[^_\s])_(?=$|[\s)\]}:;,.!?])/g, '$1$2')
.trim()
while (true) {
const next = sanitized
.replace(/\s*\((?:#\d+|[0-9a-f]{7,40})\)\s*$/i, '')
.trim()
if (next === sanitized) {
break
}
sanitized = next
}
return sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim()
}
function encodeSectionHeader(title: string): string {
return `${SECTION_HEADER_PREFIX}${title}`
}
export function isReleaseSectionHeader(note: string): boolean {
return note.startsWith(SECTION_HEADER_PREFIX)
}
export function getReleaseSectionHeaderTitle(note: string): string {
return isReleaseSectionHeader(note)
? note.slice(SECTION_HEADER_PREFIX.length)
: note
}
export function parseGitHubReleaseBody(body: string): string[] {
const notes: string[] = []
let pendingSection: string | null = null
for (const rawLine of body.split('\n')) {
const line = rawLine.trim()
if (!line) {
continue
}
if (line.startsWith('### ')) {
const title = sanitizeReleaseNote(line.slice(4))
pendingSection = title || null
continue
}
if (!line.startsWith('- ') && !line.startsWith('* ')) {
continue
}
const note = sanitizeReleaseNote(line.slice(2).trim())
if (!note) {
continue
}
if (pendingSection) {
notes.push(encodeSectionHeader(pendingSection))
pendingSection = null
}
notes.push(note)
}
return notes
}
function releaseTagToVersion(tagName: string): string {
return normalizePublicVersion(tagName)
}
export function serializeGitHubReleasesAsChangelog(
releases: GitHubRelease[],
): string {
return releases
.filter(release => !release.draft && !release.prerelease)
.map(release => {
const version = release.tag_name
? releaseTagToVersion(release.tag_name)
: ''
const notes = parseGitHubReleaseBody(release.body ?? '')
if (!version || notes.length === 0) {
return null
}
return [`## ${version}`, ...notes.map(note => `- ${note}`)].join('\n')
})
.filter((section): section is string => section !== null)
.join('\n\n')
}
export function getReleaseNotesForVersionFromReleases(
version: string,
releases: GitHubRelease[],
): string[] {
const normalizedVersion = normalizePublicVersion(version)
const release = releases.find(candidate => {
if (!candidate.tag_name || candidate.draft || candidate.prerelease) {
return false
}
return releaseTagToVersion(candidate.tag_name) === normalizedVersion
})
return release ? parseGitHubReleaseBody(release.body ?? '') : []
}
async function fetchGitHubReleases(): Promise<GitHubRelease[]> {
const response = await axios.get<GitHubRelease[]>(RELEASES_API_URL, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'openclaude',
},
})
if (!Array.isArray(response.data)) {
return []
}
return response.status === 200 ? response.data : []
}
async function storeSerializedChangelog(changelogContent: string): Promise<void> {
// Skip write if content unchanged — writing Date.now() defeats the
// dirty-check in saveGlobalConfig since the timestamp always differs.
if (changelogContent === changelogMemoryCache) {
return
}
const cachePath = getChangelogCachePath()
// Ensure cache directory exists
await mkdir(dirname(cachePath), { recursive: true })
// Write changelog to cache file
await writeFile(cachePath, changelogContent, { encoding: 'utf-8' })
changelogMemoryCache = changelogContent
// Update timestamp in config
const changelogLastFetched = Date.now()
saveGlobalConfig(current => ({
...current,
changelogLastFetched,
}))
}
/**
* Migrate changelog from old config-based storage to file-based storage.
* This should be called once at startup to ensure the migration happens
@@ -90,32 +253,29 @@ export async function fetchAndStoreChangelog(): Promise<void> {
return
}
const response = await axios.get(RAW_CHANGELOG_URL)
if (response.status === 200) {
const changelogContent = response.data
const releases = await fetchGitHubReleases()
await storeSerializedChangelog(serializeGitHubReleasesAsChangelog(releases))
}
// Skip write if content unchanged — writing Date.now() defeats the
// dirty-check in saveGlobalConfig since the timestamp always differs.
if (changelogContent === changelogMemoryCache) {
return
}
const cachePath = getChangelogCachePath()
// Ensure cache directory exists
await mkdir(dirname(cachePath), { recursive: true })
// Write changelog to cache file
await writeFile(cachePath, changelogContent, { encoding: 'utf-8' })
changelogMemoryCache = changelogContent
// Update timestamp in config
const changelogLastFetched = Date.now()
saveGlobalConfig(current => ({
...current,
changelogLastFetched,
}))
export async function fetchReleaseNotesForVersion(
version: string,
): Promise<string[]> {
if (getIsNonInteractiveSession()) {
return []
}
if (isEssentialTrafficOnly()) {
return []
}
const releases = await fetchGitHubReleases()
const notes = getReleaseNotesForVersionFromReleases(version, releases)
if (notes.length > 0) {
await storeSerializedChangelog(serializeGitHubReleasesAsChangelog(releases))
}
return notes
}
/**
@@ -149,7 +309,7 @@ export function getStoredChangelogFromMemory(): string {
}
/**
* Parses a changelog string in markdown format into a structured format
* Parses a cached release-notes string into a structured format.
* @param content - The changelog content string
* @returns Record mapping version numbers to arrays of release notes
*/
@@ -167,19 +327,21 @@ export function parseChangelog(content: string): Record<string, string[]> {
const lines = section.trim().split('\n')
if (lines.length === 0) continue
// Extract version from the first line
// Handle both "1.2.3" and "1.2.3 - YYYY-MM-DD" formats
// Normalize public versions so plain headings, dated headings, and
// release-please markdown links all map to the same lookup key.
const versionLine = lines[0]
if (!versionLine) continue
// First part before any dash is the version
const version = versionLine.split(' - ')[0]?.trim() || ''
const version = normalizePublicVersion(versionLine)
if (!version) continue
// Extract bullet points
const notes = lines
.slice(1)
.filter(line => line.trim().startsWith('- '))
.filter(line => {
const trimmed = line.trim()
return trimmed.startsWith('- ') || trimmed.startsWith('* ')
})
.map(line => line.trim().substring(2).trim())
.filter(Boolean)
@@ -214,7 +376,18 @@ export function getRecentReleaseNotes(
// Strip SHA from both versions to compare only the base versions
const baseCurrentVersion = coerce(currentVersion)
const basePreviousVersion = previousVersion ? coerce(previousVersion) : null
let basePreviousVersion = previousVersion ? coerce(previousVersion) : null
// Older OpenClaude builds stored the internal compatibility version
// (e.g. 99.0.0) as the "seen" marker. Treat that as unseen so users
// can start receiving release notes keyed to the public version.
if (
baseCurrentVersion &&
basePreviousVersion &&
gt(basePreviousVersion.version, baseCurrentVersion.version)
) {
basePreviousVersion = null
}
if (
!basePreviousVersion ||
@@ -239,6 +412,70 @@ export function getRecentReleaseNotes(
return []
}
export function getReleaseNotesForVersion(
version: string,
changelogContent: string = getStoredChangelogFromMemory(),
): string[] {
try {
const releaseNotes = parseChangelog(changelogContent)
return releaseNotes[normalizePublicVersion(version)] ?? []
} catch (error) {
logError(toError(error))
return []
}
}
export function formatReleaseNotesForDisplay(notes: string[]): string {
const lines: string[] = []
for (const note of notes) {
if (isReleaseSectionHeader(note)) {
if (lines.length > 0) {
lines.push('')
}
lines.push(`${getReleaseSectionHeaderTitle(note)}:`)
continue
}
lines.push(`- ${note}`)
}
return lines.join('\n')
}
export function sliceReleaseNotesForDisplay(
notes: string[],
maxItems: number,
): string[] {
if (maxItems <= 0) {
return []
}
const result: string[] = []
for (const note of notes) {
if (result.length >= maxItems) {
break
}
if (isReleaseSectionHeader(note)) {
if (result.length + 1 >= maxItems) {
break
}
result.push(note)
continue
}
result.push(note)
}
while (result.length > 0 && isReleaseSectionHeader(result[result.length - 1]!)) {
result.pop()
}
return result
}
/**
* Gets all release notes as an array of [version, notes] arrays.
* Versions are sorted with oldest first.
@@ -286,7 +523,7 @@ export function getAllReleaseNotes(
*/
export async function checkForReleaseNotes(
lastSeenVersion: string | null | undefined,
currentVersion: string = MACRO.VERSION,
currentVersion: string = publicBuildVersion,
): Promise<{ hasReleaseNotes: boolean; releaseNotes: string[] }> {
// For Ant builds, use VERSION_CHANGELOG bundled at build time
if (process.env.USER_TYPE === 'ant') {
@@ -334,7 +571,7 @@ export async function checkForReleaseNotes(
*/
export function checkForReleaseNotesSync(
lastSeenVersion: string | null | undefined,
currentVersion: string = MACRO.VERSION,
currentVersion: string = publicBuildVersion,
): { hasReleaseNotes: boolean; releaseNotes: string[] } {
// For Ant builds, use VERSION_CHANGELOG bundled at build time
if (process.env.USER_TYPE === 'ant') {
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { coerce } from 'semver'
export const OPENCLAUDE_RELEASES_URL =
'https://github.com/Gitlawb/openclaude/releases'
export function normalizePublicVersion(version: string): string {
const trimmedVersion = version.trim()
const coercedVersion = coerce(trimmedVersion)
if (coercedVersion) {
return coercedVersion.version
}
return trimmedVersion.replace(/^v/i, '')
}
function readPackageVersionFromDisk(): string | null {
let currentDir = dirname(fileURLToPath(import.meta.url))
while (true) {
const packageJsonPath = join(currentDir, 'package.json')
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
version?: unknown
}
if (typeof pkg.version === 'string' && pkg.version.trim()) {
return pkg.version
}
} catch {
// Keep walking upward until we find the package root.
}
const parentDir = dirname(currentDir)
if (parentDir === currentDir) {
return null
}
currentDir = parentDir
}
}
const fallbackBuildVersion = (() => {
try {
return MACRO.VERSION
} catch {
return '0.0.0'
}
})()
export const publicBuildVersion = normalizePublicVersion(
readPackageVersionFromDisk() ?? fallbackBuildVersion,
)
export function getReleaseTagUrl(version: string = publicBuildVersion): string {
return `${OPENCLAUDE_RELEASES_URL}/tag/v${normalizePublicVersion(version)}`
}