diff --git a/src/commands/check-update.ts b/src/commands/check-update.ts index 8700ac8bb..628c31772 100644 --- a/src/commands/check-update.ts +++ b/src/commands/check-update.ts @@ -2,6 +2,7 @@ import { VERSION } from '../version.ts'; import { detectInstallMethod } from './upgrade.ts'; import { isMinorOrMajorBump, + isNewerVersion, isValidVersionString, parseSemver, semverGt, @@ -21,7 +22,7 @@ function safeWriteCache(marker: UpdateMarker): void { // Back-compat re-exports: these used to live here; moved to ../core/semver.ts // so the self-upgrade decision module can depend on them without an import // cycle. Existing importers (`test/check-update.test.ts`, etc.) keep working. -export { parseSemver, isMinorOrMajorBump }; +export { parseSemver, isMinorOrMajorBump, isNewerVersion }; interface CheckUpdateResult { current_version: string; @@ -131,7 +132,7 @@ export async function refreshUpdateCache(): Promise { return; } const latestVersion = release.tag.replace(/^v/, ''); - if (!isValidVersionString(latestVersion) || !isMinorOrMajorBump(VERSION, latestVersion)) { + if (!isValidVersionString(latestVersion) || !isNewerVersion(VERSION, latestVersion)) { safeWriteCache({ kind: 'up_to_date', current: VERSION }); return; } @@ -140,7 +141,7 @@ export async function refreshUpdateCache(): Promise { export async function runCheckUpdate(args: string[]) { if (args.includes('--help') || args.includes('-h')) { - console.log('Usage: gbrain check-update [--json] [--refresh-cache]\n\nCheck for new GBrain versions.\n\nOnly reports minor/major version bumps (v0.X.0), not patches.\nFails silently on network errors.\n\n--refresh-cache Fetch + update the self-upgrade cache, print nothing (used by\n the CLI startup hook\'s detached refresh).'); + console.log('Usage: gbrain check-update [--json] [--refresh-cache]\n\nCheck for new GBrain versions.\n\nReports any strictly newer release, including patch and micro updates.\nFails silently on network errors.\n\n--refresh-cache Fetch + update the self-upgrade cache, print nothing (used by\n the CLI startup hook\'s detached refresh).'); return; } @@ -187,7 +188,7 @@ export async function runCheckUpdate(args: string[]) { } const latestVersion = release.tag.replace(/^v/, ''); - const updateAvailable = isValidVersionString(latestVersion) && isMinorOrMajorBump(VERSION, latestVersion); + const updateAvailable = isValidVersionString(latestVersion) && isNewerVersion(VERSION, latestVersion); // Warm the self-upgrade cache so the next `gbrain ` startup hook can emit // the marker without a network call. diff --git a/src/commands/self-upgrade.ts b/src/commands/self-upgrade.ts index bde28a020..c0d96bde0 100644 --- a/src/commands/self-upgrade.ts +++ b/src/commands/self-upgrade.ts @@ -1,5 +1,5 @@ import { VERSION } from '../version.ts'; -import { isMinorOrMajorBump, isValidVersionString } from '../core/semver.ts'; +import { isNewerVersion, isValidVersionString } from '../core/semver.ts'; import { fetchChangelog, fetchLatestRelease } from './check-update.ts'; import { detectInstallMethod, runUpgrade } from './upgrade.ts'; import { writeUpdateCache } from '../core/self-upgrade.ts'; @@ -37,7 +37,7 @@ export async function runSelfUpgrade(args: string[]): Promise { const release = await fetchLatestRelease(); const latest = release ? release.tag.replace(/^v/, '') : null; - const behind = !!latest && isValidVersionString(latest) && isMinorOrMajorBump(VERSION, latest); + const behind = !!latest && isValidVersionString(latest) && isNewerVersion(VERSION, latest); // Warm the cache so the next invocation's startup hook can emit without a fetch. try { diff --git a/src/core/self-upgrade.ts b/src/core/self-upgrade.ts index 081c93a39..45da25150 100644 --- a/src/core/self-upgrade.ts +++ b/src/core/self-upgrade.ts @@ -30,7 +30,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unl import { dirname, join } from 'node:path'; import { gbrainPath } from './config.ts'; import { acquirePackLock, type PackLockOpts } from './schema-pack/pack-lock.ts'; -import { isMinorOrMajorBump, isValidVersionString, parseSemver, semverGt, semverLte } from './semver.ts'; +import { isValidVersionString, parseSemver, semverGt, semverLte } from './semver.ts'; // ── Constants ─────────────────────────────────────────────────────────────── @@ -120,7 +120,7 @@ export interface SnoozeRecord { /** * Decide what to do about a possible upgrade. Pure: all I/O-derived inputs are * resolved by the caller. The version comparison is monotonic — we only ever - * act when `latest` is a real minor/major bump strictly greater than `current`, + * act when `latest` is a real release strictly greater than `current`, * so a downgrade / yanked / prerelease-local-build can never trigger an upgrade. */ export function decideSelfUpgrade(inp: DecideSelfUpgradeInputs): SelfUpgradeDecision { @@ -148,15 +148,11 @@ export function decideSelfUpgrade(inp: DecideSelfUpgradeInputs): SelfUpgradeDeci return { action: 'not_behind', reason: 'already current', ...base }; } - if (!isMinorOrMajorBump(inp.currentVersion, inp.latestVersion)) { - return { action: 'not_behind', reason: 'patch/micro bump only (ignored)', ...base }; - } - if (inp.failedVersions.includes(inp.latestVersion)) { return { action: 'known_bad', reason: `${inp.latestVersion} previously failed; not retrying`, ...base }; } - // Genuinely behind by a minor/major bump and not known-bad. + // Genuinely behind by a newer release and not known-bad. if (inp.channel === 'invocation') { if (inp.snoozed) { return { action: 'throttled', reason: 'snoozed for this version', ...base }; diff --git a/src/core/semver.ts b/src/core/semver.ts index db2b6796b..3afb7c0d1 100644 --- a/src/core/semver.ts +++ b/src/core/semver.ts @@ -3,20 +3,17 @@ * the update-check path and the new self-upgrade decision module * (`src/core/self-upgrade.ts`) can depend on them without an import cycle * (self-upgrade ← check-update would cycle once check-update imports the - * cache helpers back from self-upgrade). `check-update.ts` re-exports - * `parseSemver` / `isMinorOrMajorBump` for back-compat with existing importers. + * cache helpers back from self-upgrade). `check-update.ts` re-exports the + * public helpers for back-compat with existing importers. * * Supports both 3-segment (`0.41.38`) and 4-segment (`0.42.3.0`) gbrain * version strings. The 4th `.MICRO` segment is gbrain's dot-suffix * follow-up channel; comparisons use it as a 4th ordering key. */ -/** A parsed version tuple (major, minor, patch). The 4th `.MICRO` segment is - * deliberately NOT compared — micro bumps collapse to "equal" with the patch, - * which is the desired "ignored" behavior for the self-upgrade decision (we - * only ever act on minor/major bumps). Kept 3-wide for back-compat with - * existing `parseSemver` callers/tests. */ -export type SemverTuple = [number, number, number]; +/** A parsed gbrain version tuple (major, minor, patch, micro). Historical + * 3-segment versions are normalized with a zero micro segment. */ +export type SemverTuple = [number, number, number, number]; /** Strict shape gate for a remote version string before it reaches the agent. * Accepts both 3-segment (`0.41.38`) and 4-segment (`0.42.3.0`) gbrain versions. */ @@ -28,22 +25,23 @@ export function isValidVersionString(v: string): boolean { } /** - * Parse a version string into a (major, minor, patch) tuple. Returns null on - * any non-numeric or too-short input. Accepts a leading `v`. A 4th `.MICRO` - * segment is accepted by the shape gate but truncated here. + * Parse a version string into a (major, minor, patch, micro) tuple. Returns + * null on any malformed input. Accepts a leading `v`; historical 3-segment + * versions are padded with a zero micro segment. */ export function parseSemver(v: string): SemverTuple | null { const clean = v.replace(/^v/, ''); + if (!VERSION_RE.test(clean)) return null; const parts = clean.split('.'); if (parts.length < 3) return null; - const nums = parts.slice(0, 3).map(Number); + const nums = parts.map(Number); if (nums.some((n) => !Number.isFinite(n))) return null; - return [nums[0], nums[1], nums[2]]; + return [nums[0], nums[1], nums[2], nums[3] ?? 0]; } /** Strict greater-than over the tuple. */ export function semverGt(a: SemverTuple, b: SemverTuple): boolean { - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 4; i++) { if (a[i] !== b[i]) return a[i] > b[i]; } return false; @@ -54,10 +52,17 @@ export function semverLte(a: SemverTuple, b: SemverTuple): boolean { return !semverGt(a, b); } +/** True when `latest` is any strictly newer gbrain release than `current`. */ +export function isNewerVersion(current: string, latest: string): boolean { + const cur = parseSemver(current); + const lat = parseSemver(latest); + return !!cur && !!lat && semverGt(lat, cur); +} + /** * True when `latest` is a minor or major bump over `current` (patch / micro - * bumps are deliberately ignored, matching `gbrain check-update`'s - * established posture — patch noise should not nag every invocation). + * bumps are deliberately ignored). Kept for callers that intentionally want + * coarse release-channel drift rather than a general update check. * Unparseable inputs are treated as "not a bump" (fail-open to up-to-date). */ export function isMinorOrMajorBump(current: string, latest: string): boolean { diff --git a/test/check-update-refresh.serial.test.ts b/test/check-update-refresh.serial.test.ts index 8703bf302..300a456cf 100644 --- a/test/check-update-refresh.serial.test.ts +++ b/test/check-update-refresh.serial.test.ts @@ -20,10 +20,11 @@ const realFetch = globalThis.fetch; let homeDir: string; let priorHome: string | undefined; -function bump(kind: 'minor' | 'patch'): string { +function bump(kind: 'minor' | 'patch' | 'micro'): string { const v = parseSemver(VERSION)!; - if (kind === 'minor') return `${v[0]}.${v[1] + 1}.0`; - return `${v[0]}.${v[1]}.${v[2] + 1}`; + if (kind === 'minor') return `${v[0]}.${v[1] + 1}.0.0`; + if (kind === 'patch') return `${v[0]}.${v[1]}.${v[2] + 1}.0`; + return `${v[0]}.${v[1]}.${v[2]}.${v[3] + 1}`; } function stubReleaseFetch(tag: string | null, ok = true): void { @@ -62,10 +63,18 @@ describe('refreshUpdateCache — full refresh orchestration (network stubbed)', expect(entry?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest }); }); - test('patch-only release → writes up_to_date marker (patch ignored)', async () => { - stubReleaseFetch(`v${bump('patch')}`); + test('patch release → writes upgrade_available marker', async () => { + const latest = bump('patch'); + stubReleaseFetch(`v${latest}`); await refreshUpdateCache(); - expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION }); + expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest }); + }); + + test('micro release → writes upgrade_available marker', async () => { + const latest = bump('micro'); + stubReleaseFetch(`v${latest}`); + await refreshUpdateCache(); + expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest }); }); test('network failure → writes up_to_date marker (fail-open, TTL prevents hammering)', async () => { diff --git a/test/check-update.test.ts b/test/check-update.test.ts index 1f82404ce..6a2e2b6b9 100644 --- a/test/check-update.test.ts +++ b/test/check-update.test.ts @@ -1,13 +1,18 @@ import { describe, test, expect } from 'bun:test'; -import { parseSemver, isMinorOrMajorBump, extractChangelogBetween } from '../src/commands/check-update.ts'; +import { + parseSemver, + isMinorOrMajorBump, + isNewerVersion, + extractChangelogBetween, +} from '../src/commands/check-update.ts'; describe('parseSemver', () => { test('parses standard version', () => { - expect(parseSemver('0.4.0')).toEqual([0, 4, 0]); + expect(parseSemver('0.4.0')).toEqual([0, 4, 0, 0]); }); test('strips v prefix', () => { - expect(parseSemver('v0.5.0')).toEqual([0, 5, 0]); + expect(parseSemver('v0.5.0')).toEqual([0, 5, 0, 0]); }); test('returns null for malformed version', () => { @@ -16,8 +21,24 @@ describe('parseSemver', () => { expect(parseSemver('')).toBeNull(); }); - test('handles 4-part versions (takes first 3)', () => { - expect(parseSemver('0.2.0.1')).toEqual([0, 2, 0]); + test('preserves the 4th micro segment', () => { + expect(parseSemver('0.2.0.1')).toEqual([0, 2, 0, 1]); + }); +}); + +describe('isNewerVersion', () => { + test('detects the patch segment used by the gbrain release train', () => { + expect(isNewerVersion('0.42.10.0', '0.42.66.0')).toBe(true); + }); + + test('detects a micro follow-up release', () => { + expect(isNewerVersion('0.42.66.0', '0.42.66.1')).toBe(true); + }); + + test('rejects equal, older, and malformed versions', () => { + expect(isNewerVersion('0.42.66.0', '0.42.66.0')).toBe(false); + expect(isNewerVersion('0.42.66.1', '0.42.66.0')).toBe(false); + expect(isNewerVersion('0.42.66.0', 'garbage')).toBe(false); }); }); @@ -119,6 +140,20 @@ describe('extractChangelogBetween', () => { expect(result).toContain('Major 2'); expect(result).not.toContain('Minor 5'); }); + + test('distinguishes micro releases', () => { + const micro = `# Changelog + +## [0.42.66.1] - 2026-07-26 +- Follow-up fix + +## [0.42.66.0] - 2026-07-25 +- Original release +`; + const result = extractChangelogBetween(micro, '0.42.66.0', '0.42.66.1'); + expect(result).toContain('Follow-up fix'); + expect(result).not.toContain('Original release'); + }); }); describe('check-update CLI', () => { diff --git a/test/e2e/upgrade.test.ts b/test/e2e/upgrade.test.ts index af204e489..4d8db2982 100644 --- a/test/e2e/upgrade.test.ts +++ b/test/e2e/upgrade.test.ts @@ -9,7 +9,7 @@ import { describe, test, expect } from 'bun:test'; import { VERSION } from '../../src/version.ts'; -import { isMinorOrMajorBump } from '../../src/commands/check-update.ts'; +import { isNewerVersion } from '../../src/commands/check-update.ts'; // Check if we can reach GitHub async function hasNetwork(): Promise { @@ -98,9 +98,9 @@ describeE2E('E2E: Check-Update', () => { test('version comparison wiring works end-to-end', () => { // Smoke test that the exported function works correctly - expect(isMinorOrMajorBump('0.4.0', '0.5.0')).toBe(true); - expect(isMinorOrMajorBump('0.4.0', '0.4.1')).toBe(false); - expect(isMinorOrMajorBump('0.4.0', '1.0.0')).toBe(true); - expect(isMinorOrMajorBump('0.4.0', '0.4.0')).toBe(false); + expect(isNewerVersion('0.42.10.0', '0.42.66.0')).toBe(true); + expect(isNewerVersion('0.42.66.0', '0.42.66.1')).toBe(true); + expect(isNewerVersion('0.42.66.0', '1.0.0')).toBe(true); + expect(isNewerVersion('0.42.66.0', '0.42.66.0')).toBe(false); }); }); diff --git a/test/self-upgrade-checkonly.serial.test.ts b/test/self-upgrade-checkonly.serial.test.ts index 268d65788..7f4dfd4f5 100644 --- a/test/self-upgrade-checkonly.serial.test.ts +++ b/test/self-upgrade-checkonly.serial.test.ts @@ -20,7 +20,12 @@ let captured: string[]; function minorBump(): string { const v = parseSemver(VERSION)!; - return `${v[0]}.${v[1] + 1}.0`; + return `${v[0]}.${v[1] + 1}.0.0`; +} + +function microBump(): string { + const v = parseSemver(VERSION)!; + return `${v[0]}.${v[1]}.${v[2]}.${v[3] + 1}`; } function stub(tag: string | null, changelog: string): void { @@ -83,6 +88,17 @@ describe('self-upgrade --check-only surfaces what you get', () => { expect(out.changelog_diff).toBe(''); }); + test('micro release → reports update available', async () => { + const latest = microBump(); + const changelog = `# Changelog\n\n## [${latest}] - 2026-01-01\n\n- Follow-up fix\n\n## [${VERSION}] - 2025-12-01\n\n- old\n`; + stub(`v${latest}`, changelog); + await runSelfUpgrade(['--check-only', '--json']); + const out = JSON.parse(captured.join('\n')); + expect(out.update_available).toBe(true); + expect(out.latest_version).toBe(latest); + expect(out.changelog_diff).toContain('Follow-up fix'); + }); + test('network failure → up to date, no crash', async () => { stub(null, ''); await runSelfUpgrade(['--check-only', '--json']); diff --git a/test/self-upgrade.test.ts b/test/self-upgrade.test.ts index f0f9f876f..6ab60c3a4 100644 --- a/test/self-upgrade.test.ts +++ b/test/self-upgrade.test.ts @@ -62,9 +62,9 @@ describe('decideSelfUpgrade — pure branches', () => { expect(d.action).toBe('downgrade_or_yanked'); }); - test('patch/micro bump only → not_behind (ignored)', () => { - expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.42.1' })).action).toBe('not_behind'); - expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.3.0', latestVersion: '0.42.3.1' })).action).toBe('not_behind'); + test('patch and micro releases → behind', () => { + expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.42.1' })).action).toBe('notify'); + expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.3.0', latestVersion: '0.42.3.1' })).action).toBe('notify'); }); test('minor bump → behind', () => {