mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com> Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
This commit is contained in:
co-authored by
arisgysel-design
parent
d9ac24744c
commit
4871ae0c05
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
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 <cmd>` startup hook can emit
|
||||
// the marker without a network call.
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+21
-16
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user