diff --git a/apps/version/migrations/0002_add_prerelease.sql b/apps/version/migrations/0002_add_prerelease.sql new file mode 100644 index 0000000..0ffa5b6 --- /dev/null +++ b/apps/version/migrations/0002_add_prerelease.sql @@ -0,0 +1 @@ +ALTER TABLE releases ADD prerelease INTEGER; diff --git a/apps/version/package.json b/apps/version/package.json index 8026ab3..ec77b4a 100644 --- a/apps/version/package.json +++ b/apps/version/package.json @@ -12,6 +12,10 @@ }, "dependencies": { "@influxdata/influxdb-client": "^1.34.0", - "@octokit/auth-app": "^7.2.2" + "@octokit/auth-app": "^7.2.2", + "semver": "^7.8.1" + }, + "devDependencies": { + "@types/semver": "^7.7.1" } } diff --git a/apps/version/src/github-repository.ts b/apps/version/src/github-repository.ts index 66da2c6..850bce5 100644 --- a/apps/version/src/github-repository.ts +++ b/apps/version/src/github-repository.ts @@ -1,5 +1,5 @@ +import semver from 'semver'; import type { GitHubRelease } from './types.js'; -import { compareSemVer, parseSemVer } from './version.js'; const GITHUB_RELEASES_URL = 'https://api.github.com/repos/immich-app/immich/releases'; const MAX_PAGES = 3; @@ -58,12 +58,12 @@ export class GitHubRepository implements IGitHubRepository { } allReleases.sort((a, b) => { - const semverA = parseSemVer(a.tag_name); - const semverB = parseSemVer(b.tag_name); + const semverA = semver.parse(a.tag_name); + const semverB = semver.parse(b.tag_name); if (!semverA || !semverB) { return 0; } - return compareSemVer(semverB, semverA); + return semver.compare(semverB, semverA); }); return allReleases; @@ -108,7 +108,8 @@ function parseRelease(item: unknown): GitHubRelease | null { if (!isValidRelease(item)) { return null; } - if (item.draft === true || item.prerelease === true) { + // Drafts are skipped, but pre-releases (rc builds) are kept to back the `rc` channel. + if (item.draft === true) { return null; } diff --git a/apps/version/src/index.test.ts b/apps/version/src/index.test.ts index 784f985..c314c65 100644 --- a/apps/version/src/index.test.ts +++ b/apps/version/src/index.test.ts @@ -1,13 +1,14 @@ import { env, exports } from 'cloudflare:workers'; +import semver from 'semver'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GitHubRepository } from './github-repository.js'; import { MemoryCache } from './memory-cache.js'; import { revalidationState, versionCache } from './version-service.js'; -import { compareSemVer, isGreaterThan, parseSemVer } from './version.js'; import { verifyWebhookSignature } from './webhook.js'; async function createSchema() { await env.VERSION_DB.prepare( - "CREATE TABLE IF NOT EXISTS releases (id INTEGER PRIMARY KEY, tag_name TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '', published_at TEXT NOT NULL DEFAULT '', major INTEGER NOT NULL, minor INTEGER NOT NULL, patch INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS releases (id INTEGER PRIMARY KEY, tag_name TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '', published_at TEXT NOT NULL DEFAULT '', major INTEGER NOT NULL, minor INTEGER NOT NULL, patch INTEGER NOT NULL, prerelease INTEGER)", ).run(); await env.VERSION_DB.prepare( 'CREATE INDEX IF NOT EXISTS idx_releases_semver ON releases (major DESC, minor DESC, patch DESC)', @@ -44,26 +45,41 @@ const mockReleases = [ }, ]; +interface SeedRelease { + id: number; + tag_name: string; + name?: string; + url?: string; + body?: string; + created_at?: string; + published_at?: string; +} + +async function insertRelease(release: SeedRelease) { + const parsedVersion = semver.parse(release.tag_name)!; + await env.VERSION_DB.prepare( + `INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch, prerelease) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + release.id, + release.tag_name, + release.name ?? release.tag_name, + release.url ?? '', + release.body ?? '', + release.created_at ?? '', + release.published_at ?? '', + parsedVersion.major, + parsedVersion.minor, + parsedVersion.patch, + parsedVersion.prerelease[1] ?? null, + ) + .run(); +} + async function seedReleases() { for (const release of mockReleases) { - const semver = parseSemVer(release.tag_name)!; - await env.VERSION_DB.prepare( - `INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - release.id, - release.tag_name, - release.name, - release.url, - release.body, - release.created_at, - release.published_at, - semver.major, - semver.minor, - semver.patch, - ) - .run(); + await insertRelease(release); } } @@ -134,72 +150,6 @@ describe('Webhook signature verification', () => { }); }); -describe('Version utilities', () => { - describe('parseSemVer', () => { - it('parses version with v prefix', () => { - expect(parseSemVer('v1.100.0')).toEqual({ major: 1, minor: 100, patch: 0 }); - }); - - it('parses version without v prefix', () => { - expect(parseSemVer('1.100.0')).toEqual({ major: 1, minor: 100, patch: 0 }); - }); - - it('returns null for invalid version', () => { - expect(parseSemVer('invalid')).toBeNull(); - expect(parseSemVer('')).toBeNull(); - }); - - it('returns null for version with pre-release suffix', () => { - expect(parseSemVer('v1.100.0-rc.1')).toBeNull(); - expect(parseSemVer('1.100.0-beta')).toBeNull(); - }); - - it('returns null for version with extra segments', () => { - expect(parseSemVer('v1.100.0.1')).toBeNull(); - }); - }); - - describe('compareSemVer', () => { - it('returns positive when first is greater', () => { - expect(compareSemVer({ major: 2, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 0 })).toBeGreaterThan(0); - }); - - it('returns negative when first is smaller', () => { - expect(compareSemVer({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBeLessThan(0); - }); - - it('returns 0 when equal', () => { - expect(compareSemVer({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe(0); - }); - }); - - describe('isGreaterThan', () => { - it('compares major versions', () => { - expect(isGreaterThan('v2.0.0', 'v1.0.0')).toBe(true); - expect(isGreaterThan('v1.0.0', 'v2.0.0')).toBe(false); - }); - - it('compares minor versions', () => { - expect(isGreaterThan('v1.2.0', 'v1.1.0')).toBe(true); - expect(isGreaterThan('v1.1.0', 'v1.2.0')).toBe(false); - }); - - it('compares patch versions', () => { - expect(isGreaterThan('v1.1.2', 'v1.1.1')).toBe(true); - expect(isGreaterThan('v1.1.1', 'v1.1.2')).toBe(false); - }); - - it('returns false for equal versions', () => { - expect(isGreaterThan('v1.1.1', 'v1.1.1')).toBe(false); - }); - - it('handles mixed v prefix', () => { - expect(isGreaterThan('v1.2.0', '1.1.0')).toBe(true); - expect(isGreaterThan('1.2.0', 'v1.1.0')).toBe(true); - }); - }); -}); - describe('Version Worker', () => { beforeAll(async () => { await createSchema(); @@ -241,6 +191,17 @@ describe('Version Worker', () => { expect(body.published_at).toBe('2025-03-01T00:00:00Z'); }); + it('returns the latest stable version, ignoring newer pre-releases', async () => { + // A pre-release newer than every stable release must not be served on the default (stable) channel. + await insertRelease({ id: 10, tag_name: 'v1.130.0-rc.1', published_at: '2025-04-01T00:00:00Z' }); + versionCache.invalidate(); + + const response = await exports.default.fetch('https://example.com/version'); + expect(response.status).toBe(200); + const body = (await response.json()) as any; + expect(body.version).toBe('v1.120.0'); + }); + it('awaits D1 on cold start rather than deferring', async () => { // Cache is empty (invalidated in beforeEach), D1 has data // The first request must return real data, not 404 or empty @@ -294,29 +255,17 @@ describe('Version Worker', () => { // Expire the cache by invalidating and setting with 0 TTL versionCache.invalidate(); - versionCache.set({ version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' }); + versionCache.set(new Map().set('stable', { version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' })); // Manually expire it Object.assign(versionCache, { expiresAt: 0 }); // Update D1 with a new version - const semver = parseSemVer('v1.130.0')!; - await env.VERSION_DB.prepare( - `INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - 4, - 'v1.130.0', - 'v1.130.0', - '', - '', - '2025-04-01T00:00:00Z', - '2025-04-01T00:00:00Z', - semver.major, - semver.minor, - semver.patch, - ) - .run(); + await insertRelease({ + id: 4, + tag_name: 'v1.130.0', + created_at: '2025-04-01T00:00:00Z', + published_at: '2025-04-01T00:00:00Z', + }); // This request should get stale v1.120.0 while triggering background refresh const stale = await exports.default.fetch('https://example.com/version'); @@ -336,7 +285,7 @@ describe('Version Worker', () => { it('deduplicates concurrent revalidation requests', async () => { // Set up stale cache - versionCache.set({ version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' }); + versionCache.set(new Map().set('stable', { version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' })); Object.assign(versionCache, { expiresAt: 0 }); expect(revalidationState.inFlight).toBe(false); @@ -421,6 +370,94 @@ describe('Version Worker', () => { }); }); + describe('GET /changelog - release channels', () => { + it('does not error when the requested version is a pre-release', async () => { + // Regression: getNewerThan used to bind the whole prerelease array, throwing D1_TYPE_ERROR -> 500. + const response = await exports.default.fetch('https://example.com/changelog?version=v1.121.0-rc.1'); + expect(response.status).toBe(200); + const body = (await response.json()) as any; + expect(body.current).toBe('v1.121.0-rc.1'); + }); + + it('returns 400 for an invalid channel', async () => { + const response = await exports.default.fetch('https://example.com/changelog?version=v1.100.0&channel=nightly'); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('channel'); + }); + + it('defaults to the stable channel when none is provided', async () => { + await insertRelease({ id: 20, tag_name: 'v1.121.0-rc.1' }); + + const response = await exports.default.fetch('https://example.com/changelog?version=v1.120.0'); + const body = (await response.json()) as any; + const tags = body.releases.map((r: any) => r.tag_name); + expect(tags).not.toContain('v1.121.0-rc.1'); + expect(body.latest.tag_name).toBe('v1.120.0'); + }); + + it('includes pre-releases on the rc channel but excludes them on stable', async () => { + await insertRelease({ id: 20, tag_name: 'v1.121.0-rc.1' }); + + const rc = await exports.default.fetch('https://example.com/changelog?version=v1.120.0&channel=rc'); + const rcBody = (await rc.json()) as any; + const rcTags = rcBody.releases.map((r: any) => r.tag_name); + expect(rcTags).toContain('v1.121.0-rc.1'); + expect(rcBody.latest.tag_name).toBe('v1.121.0-rc.1'); + + const stable = await exports.default.fetch('https://example.com/changelog?version=v1.120.0&channel=stable'); + const stableBody = (await stable.json()) as any; + expect(stableBody.releases).toHaveLength(0); + expect(stableBody.latest.tag_name).toBe('v1.120.0'); + }); + + it('treats a stable release as newer than its own pre-release on the rc channel', async () => { + await insertRelease({ id: 21, tag_name: 'v1.121.0-rc.1' }); + await insertRelease({ id: 22, tag_name: 'v1.121.0' }); + + const response = await exports.default.fetch('https://example.com/changelog?version=v1.120.0&channel=rc'); + const body = (await response.json()) as any; + // Stable 1.121.0 outranks 1.121.0-rc.1 in semver, so it must be both latest and first. + expect(body.latest.tag_name).toBe('v1.121.0'); + expect(body.releases[0].tag_name).toBe('v1.121.0'); + }); + + it('reports the stable as rc-channel latest once it supersedes the pre-release', async () => { + // Once 3.0.0 ships, an rc-channel client must see 3.0.0 rather than 3.0.0-rc.2. + await insertRelease({ id: 30, tag_name: 'v3.0.0-rc.2', published_at: '2025-01-01T00:00:00Z' }); + await insertRelease({ id: 31, tag_name: 'v3.0.0', published_at: '2025-02-01T00:00:00Z' }); + + const response = await exports.default.fetch('https://example.com/changelog?version=v1.0.0&channel=rc'); + const body = (await response.json()) as any; + expect(body.latest.tag_name).toBe('v3.0.0'); + }); + + it('orders rc-channel latest by semver precedence, not publish date', async () => { + // 2.8.1 is a patch to an older line, published *after* 3.0.0-rc.2. The rc channel must still + // report 3.0.0-rc.2 as latest because it is the highest semver, not the most recently published. + await insertRelease({ id: 32, tag_name: 'v3.0.0-rc.2', published_at: '2025-01-01T00:00:00Z' }); + await insertRelease({ id: 33, tag_name: 'v2.8.1', published_at: '2025-06-01T00:00:00Z' }); + + const response = await exports.default.fetch('https://example.com/changelog?version=v1.0.0&channel=rc'); + const body = (await response.json()) as any; + expect(body.latest.tag_name).toBe('v3.0.0-rc.2'); + expect(body.releases[0].tag_name).toBe('v3.0.0-rc.2'); + }); + + it('returns newer pre-releases and the matching stable for a user on a pre-release', async () => { + await insertRelease({ id: 23, tag_name: 'v1.121.0-rc.1' }); + await insertRelease({ id: 24, tag_name: 'v1.121.0-rc.2' }); + await insertRelease({ id: 25, tag_name: 'v1.121.0' }); + + const response = await exports.default.fetch('https://example.com/changelog?version=v1.121.0-rc.1&channel=rc'); + const body = (await response.json()) as any; + const tags = body.releases.map((r: any) => r.tag_name); + expect(tags).toContain('v1.121.0-rc.2'); + expect(tags).toContain('v1.121.0'); + expect(tags).not.toContain('v1.121.0-rc.1'); // a version is not newer than itself + }); + }); + describe('POST /webhook', () => { const webhookSecret = 'test-secret'; @@ -594,7 +631,7 @@ describe('Version Worker', () => { expect(result.ignored).toBe(true); }); - it('ignores prerelease releases', async () => { + it('stores prerelease releases for the rc channel', async () => { const releasePayload = { action: 'published', release: { @@ -602,9 +639,9 @@ describe('Version Worker', () => { tag_name: 'v1.121.0-rc.1', name: 'v1.121.0-rc.1', url: '', - body: '', - created_at: '', - published_at: '', + body: 'Release candidate', + created_at: '2025-03-15T00:00:00Z', + published_at: '2025-03-15T00:00:00Z', prerelease: true, }, }; @@ -621,7 +658,17 @@ describe('Version Worker', () => { }); expect(response.status).toBe(200); const result = (await response.json()) as any; - expect(result.ignored).toBe(true); + expect(result.success).toBe(true); + + // The rc build is available on the rc channel... + const rc = await exports.default.fetch('https://example.com/changelog?version=v1.120.0&channel=rc'); + const rcBody = (await rc.json()) as any; + expect(rcBody.releases.map((r: any) => r.tag_name)).toContain('v1.121.0-rc.1'); + + // ...but hidden from the stable channel. + const stable = await exports.default.fetch('https://example.com/changelog?version=v1.120.0&channel=stable'); + const stableBody = (await stable.json()) as any; + expect(stableBody.releases.map((r: any) => r.tag_name)).not.toContain('v1.121.0-rc.1'); }); }); @@ -692,6 +739,53 @@ describe('Cron sync', () => { }); }); +describe('GitHubRepository', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps pre-releases but drops drafts when syncing from GitHub', async () => { + const githubReleases = [ + { id: 1, tag_name: 'v1.120.0', name: 'v1.120.0', url: '', body: '', created_at: '', published_at: '' }, + { + id: 2, + tag_name: 'v1.121.0-rc.1', + name: 'v1.121.0-rc.1', + url: '', + body: '', + created_at: '', + published_at: '', + prerelease: true, + }, + { + id: 3, + tag_name: 'v1.122.0', + name: 'v1.122.0', + url: '', + body: '', + created_at: '', + published_at: '', + draft: true, + }, + ]; + + vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + const url = new URL(new Request(input, init).url); + if (url.origin === 'https://api.github.com' && url.pathname === '/repos/immich-app/immich/releases') { + return Promise.resolve(Response.json(githubReleases)); + } + return Promise.reject(new Error(`unexpected fetch: ${url.toString()}`)); + }); + + const releases = await new GitHubRepository().fetchReleases(); + const tags = releases.map((r) => r.tag_name); + + expect(tags).toContain('v1.121.0-rc.1'); // pre-release retained for the rc channel + expect(tags).toContain('v1.120.0'); + expect(tags).not.toContain('v1.122.0'); // draft still dropped + }); +}); + describe('CDN cache immutable headers fix', () => { it('cached responses must be wrapped to allow header mutation', async () => { const cache = caches.default; diff --git a/apps/version/src/index.ts b/apps/version/src/index.ts index 955fbaf..90789b1 100644 --- a/apps/version/src/index.ts +++ b/apps/version/src/index.ts @@ -1,3 +1,4 @@ +import semver from 'semver'; import { DeferredRepository } from './deferred.js'; import { createInstallationToken } from './github-auth.js'; import { GitHubRepository } from './github-repository.js'; @@ -5,7 +6,6 @@ import { CloudflareMetricsRepository, HeaderMetricsProvider, InfluxMetricsProvid import { ReleaseRepository } from './release-repository.js'; import type { GitHubRelease } from './types.js'; import { VersionService } from './version-service.js'; -import { parseSemVer } from './version.js'; import { verifyWebhookSignature } from './webhook.js'; const DEFAULT_HEADERS: Record = { @@ -70,7 +70,14 @@ export default { }, }, async (): Promise => { - const latest = await versionService.getLatestVersion(deferredRepository); + // we assume stable for backwards compatibility + const channel = url.searchParams.get('channel') ?? 'stable'; + + if (!versionService.isValidChannel(channel)) { + return errorResponse('Invalid release channel. Expected "stable" or "rc"', 400); + } + + const latest = await versionService.getLatestVersion(deferredRepository, channel); if (!latest) { return errorResponse('No releases found', 404); } @@ -85,10 +92,17 @@ export default { return errorResponse('Missing required query parameter: version', 400); } - if (!parseSemVer(version)) { + if (!semver.valid(version)) { return errorResponse('Invalid version format. Expected semver (e.g., 1.100.0 or v1.100.0)', 400); } + // we assume stable for backwards compatibility + const channel = url.searchParams.get('channel') ?? 'stable'; + + if (!versionService.isValidChannel(channel)) { + return errorResponse('Invalid release channel. Expected "stable" or "rc"', 400); + } + const requestTags = { version, client_ip: request.headers.get('CF-Connecting-IP') ?? '', @@ -114,7 +128,7 @@ export default { return await metrics.monitorAsyncFunction( { name: 'changelog_request', tags: requestTags }, async (): Promise => { - const changelog = await versionService.getChangelog(version); + const changelog = await versionService.getChangelog(version, channel); const response = jsonResponse(changelog, 200, { 'Cache-Control': 'public, max-age=86400' }); if (env.ENVIRONMENT) { const cache = caches.default; @@ -163,7 +177,8 @@ export default { return errorResponse('Invalid release payload', 400); } - if (releaseData.draft || releaseData.prerelease) { + // Drafts are ignored, but pre-releases (rc builds) are stored to back the `rc` channel. + if (releaseData.draft) { return jsonResponse({ ignored: true }); } diff --git a/apps/version/src/release-repository.ts b/apps/version/src/release-repository.ts index dd4f71e..5329d1b 100644 --- a/apps/version/src/release-repository.ts +++ b/apps/version/src/release-repository.ts @@ -1,5 +1,5 @@ +import { parse, type SemVer } from 'semver'; import type { GitHubRelease } from './types.js'; -import { type SemVer, parseSemVer } from './version.js'; interface ReleaseRow { id: number; @@ -11,9 +11,12 @@ interface ReleaseRow { published_at: string; } +export const releaseChannels = ['stable', 'rc'] as const; +export type ReleaseChannel = (typeof releaseChannels)[number]; + export interface IReleaseRepository { - getLatest(): Promise; - getNewerThan(version: SemVer): Promise; + getLatest(channel?: ReleaseChannel): Promise; + getNewerThan(version: SemVer, channel?: ReleaseChannel): Promise; getCount(): Promise; upsert(release: GitHubRelease): Promise; bulkUpsert(releases: GitHubRelease[]): Promise; @@ -22,11 +25,18 @@ export interface IReleaseRepository { export class ReleaseRepository implements IReleaseRepository { constructor(private db: D1Database) {} - async getLatest(): Promise { + async getLatest(channel: ReleaseChannel = 'stable'): Promise { + // The `rc` channel sees every release; `stable` only sees rows without a prerelease component. + // Within the same major.minor.patch a stable release outranks its own pre-releases (1.0.0 > 1.0.0-rc.1), + // so order stable (prerelease IS NULL) ahead of pre-releases before falling back to the prerelease number. const row = await this.db .prepare( - 'SELECT id, tag_name, name, url, body, created_at, published_at FROM releases ORDER BY major DESC, minor DESC, patch DESC LIMIT 1', + `SELECT id, tag_name, name, url, body, created_at, published_at FROM releases + WHERE ?1 = 'rc' OR prerelease IS NULL + ORDER BY major DESC, minor DESC, patch DESC, (prerelease IS NULL) DESC, prerelease DESC + LIMIT 1`, ) + .bind(channel) .first(); return row ? toGitHubRelease(row) : null; @@ -37,31 +47,38 @@ export class ReleaseRepository implements IReleaseRepository { return row?.count ?? 0; } - async getNewerThan(version: SemVer): Promise { + async getNewerThan(version: SemVer, channel: ReleaseChannel = 'stable'): Promise { + // `version.prerelease` is an array (e.g. ['rc', 1] for v1.0.0-rc.1); bind only the numeric + // component to match the `prerelease` column. Binding the array itself throws D1_TYPE_ERROR. + const prerelease = version.prerelease[1] ?? null; const { results } = await this.db .prepare( `SELECT id, tag_name, name, url, body, created_at, published_at FROM releases - WHERE major > ?1 - OR (major = ?1 AND minor > ?2) - OR (major = ?1 AND minor = ?2 AND patch > ?3) - ORDER BY major DESC, minor DESC, patch DESC`, + WHERE (?5 = 'rc' OR prerelease IS NULL) + AND ( + major > ?1 + OR (major = ?1 AND minor > ?2) + OR (major = ?1 AND minor = ?2 AND patch > ?3) + OR (major = ?1 AND minor = ?2 AND patch = ?3 AND ?4 IS NOT NULL AND (prerelease IS NULL OR prerelease > ?4)) + ) + ORDER BY major DESC, minor DESC, patch DESC, (prerelease IS NULL) DESC, prerelease DESC`, ) - .bind(version.major, version.minor, version.patch) + .bind(version.major, version.minor, version.patch, prerelease, channel) .all(); return results.map((row) => toGitHubRelease(row)); } async upsert(release: GitHubRelease): Promise { - const semver = parseSemVer(release.tag_name); + const semver = parse(release.tag_name); if (!semver) { return; } await this.db .prepare( - `INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`, + `INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch, prerelease) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`, ) .bind( release.id, @@ -74,6 +91,7 @@ export class ReleaseRepository implements IReleaseRepository { semver.major, semver.minor, semver.patch, + semver.prerelease[1] ?? null, ) .run(); } @@ -82,7 +100,7 @@ export class ReleaseRepository implements IReleaseRepository { const statements: D1PreparedStatement[] = []; for (const release of releases) { - const semver = parseSemVer(release.tag_name); + const semver = parse(release.tag_name); if (!semver) { continue; } @@ -90,8 +108,8 @@ export class ReleaseRepository implements IReleaseRepository { statements.push( this.db .prepare( - `INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`, + `INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch, prerelease) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`, ) .bind( release.id, @@ -104,6 +122,7 @@ export class ReleaseRepository implements IReleaseRepository { semver.major, semver.minor, semver.patch, + semver.prerelease[1] ?? null, ), ); } diff --git a/apps/version/src/version-service.ts b/apps/version/src/version-service.ts index ef29501..876f3b7 100644 --- a/apps/version/src/version-service.ts +++ b/apps/version/src/version-service.ts @@ -1,15 +1,15 @@ +import semver from 'semver'; import type { DeferredRepository } from './deferred.js'; import type { IGitHubRepository } from './github-repository.js'; import { MemoryCache } from './memory-cache.js'; -import { type IMetricsRepository, Metric } from './metrics.js'; -import type { IReleaseRepository } from './release-repository.js'; +import { Metric, type IMetricsRepository } from './metrics.js'; +import { releaseChannels, type IReleaseRepository, type ReleaseChannel } from './release-repository.js'; import type { ChangelogResponse, GitHubRelease, VersionResponse } from './types.js'; -import { parseSemVer } from './version.js'; const VERSION_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes // Module-level state - persists across requests within the same isolate -export const versionCache = new MemoryCache(VERSION_CACHE_TTL_MS); +export const versionCache = new MemoryCache>(VERSION_CACHE_TTL_MS); export const revalidationState = { inFlight: false }; export class VersionService { @@ -18,12 +18,12 @@ export class VersionService { private metrics: IMetricsRepository, ) {} - async getLatestVersion(deferred: DeferredRepository): Promise { + async getLatestVersion(deferred: DeferredRepository, channel: ReleaseChannel): Promise { const cached = versionCache.get(); - if (cached && !cached.stale) { + if (cached && !cached.stale && cached.value.has(channel)) { this.metrics.push(Metric.create('memory_cache_hit').intField('count', 1)); - return cached.value; + return cached.value.get(channel)!; } if (cached?.stale) { @@ -38,35 +38,46 @@ export class VersionService { } }); } - return cached.value; + return cached.value.get(channel) ?? null; } this.metrics.push(Metric.create('memory_cache_miss').intField('count', 1)); - return this.refreshVersionCache(); + const releases = await this.refreshVersionCache(); + return releases.get(channel) ?? null; } - private async refreshVersionCache(): Promise { - const latest = await this.metrics.monitorAsyncFunction({ name: 'd1_get_latest' }, () => - this.releaseRepository.getLatest(), - )(); - - if (!latest) { - return null; - } + private async refreshVersionCache(): Promise> { + const latest = await this.metrics.monitorAsyncFunction({ name: 'd1_get_latest' }, async () => { + const releases = await Promise.all( + releaseChannels.map(async (channel) => [channel, await this.releaseRepository.getLatest(channel)] as const), + ); + return new Map(releases); + })(); + + const response = new Map( + [...latest.entries()] + .filter(([_, release]) => release !== null) + .map( + ([channel, release]) => + [channel, { version: release!.tag_name, published_at: release!.published_at }] as const, + ), + ); - const response: VersionResponse = { version: latest.tag_name, published_at: latest.published_at }; versionCache.set(response); return response; } - async getChangelog(version: string): Promise { - const semver = parseSemVer(version); - if (!semver) { + async getChangelog(version: string, channel: ReleaseChannel): Promise { + const parsedVersion = semver.parse(version); + if (!parsedVersion) { throw new Error('Invalid version'); } const [newerReleases, latest] = await this.metrics.monitorAsyncFunction({ name: 'd1_get_changelog' }, () => - Promise.all([this.releaseRepository.getNewerThan(semver), this.releaseRepository.getLatest()]), + Promise.all([ + this.releaseRepository.getNewerThan(parsedVersion, channel), + this.releaseRepository.getLatest(channel), + ]), )(); return { current: version, latest, releases: newerReleases }; @@ -146,6 +157,10 @@ export class VersionService { } } + isValidChannel(channel: string): channel is ReleaseChannel { + return ['rc', 'stable'].includes(channel); + } + private async emitReleaseCount(): Promise { const count = await this.releaseRepository.getCount(); this.metrics.push(Metric.create('d1_release_count').intField('count', count)); diff --git a/apps/version/src/version.ts b/apps/version/src/version.ts deleted file mode 100644 index 9d8d8a9..0000000 --- a/apps/version/src/version.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface SemVer { - major: number; - minor: number; - patch: number; -} - -export function parseSemVer(version: string): SemVer | null { - const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version); - if (!match) { - return null; - } - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - }; -} - -export function compareSemVer(a: SemVer, b: SemVer): number { - if (a.major !== b.major) { - return a.major - b.major; - } - if (a.minor !== b.minor) { - return a.minor - b.minor; - } - return a.patch - b.patch; -} - -export function isGreaterThan(a: string, b: string): boolean { - const parsedA = parseSemVer(a); - const parsedB = parseSemVer(b); - if (!parsedA || !parsedB) { - return false; - } - return compareSemVer(parsedA, parsedB) > 0; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9aee9c..5910ad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,13 @@ importers: '@octokit/auth-app': specifier: ^7.2.2 version: 7.2.2 + semver: + specifier: ^7.8.1 + version: 7.8.1 + devDependencies: + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 packages: @@ -1053,6 +1060,9 @@ packages: '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@typescript-eslint/eslint-plugin@8.59.2': resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1583,6 +1593,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2554,6 +2569,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/semver@7.7.1': {} + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3143,6 +3160,8 @@ snapshots: semver@7.8.0: {} + semver@7.8.1: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0