From d48152ddc477da7896872d249edfb19af047b9e7 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Thu, 28 May 2026 18:09:38 +0200 Subject: [PATCH] feat: release candidate support --- .../model/server_version_response_dto.dart | 22 +++++++- open-api/immich-openapi-specs.json | 16 +++++- packages/sdk/src/fetch-client.ts | 2 + pnpm-lock.yaml | 47 +++++++++------- server/package.json | 2 +- server/src/dtos/server.dto.ts | 9 ++- server/src/services/version.service.spec.ts | 49 ++++++++++++++-- server/src/services/version.service.ts | 12 +++- .../side-bar/ServerStatus.svelte | 4 +- web/src/lib/utils.spec.ts | 56 ++++++++++++++++--- web/src/lib/utils.ts | 3 +- 11 files changed, 175 insertions(+), 47 deletions(-) diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index 60161a7458..261e196850 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -16,6 +16,7 @@ class ServerVersionResponseDto { required this.major, required this.minor, required this.patch_, + required this.prerelease, }); /// Major version number @@ -36,27 +37,40 @@ class ServerVersionResponseDto { /// Maximum value: 9007199254740991 int patch_; + /// Pre-release version number + /// + /// Minimum value: -9007199254740991 + /// Maximum value: 9007199254740991 + int? prerelease; + @override bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto && other.major == major && other.minor == minor && - other.patch_ == patch_; + other.patch_ == patch_ && + other.prerelease == prerelease; @override int get hashCode => // ignore: unnecessary_parenthesis (major.hashCode) + (minor.hashCode) + - (patch_.hashCode); + (patch_.hashCode) + + (prerelease == null ? 0 : prerelease!.hashCode); @override - String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]'; + String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]'; Map toJson() { final json = {}; json[r'major'] = this.major; json[r'minor'] = this.minor; json[r'patch'] = this.patch_; + if (this.prerelease != null) { + json[r'prerelease'] = this.prerelease; + } else { + // json[r'prerelease'] = null; + } return json; } @@ -72,6 +86,7 @@ class ServerVersionResponseDto { major: mapValueOfType(json, r'major')!, minor: mapValueOfType(json, r'minor')!, patch_: mapValueOfType(json, r'patch')!, + prerelease: mapValueOfType(json, r'prerelease'), ); } return null; @@ -122,6 +137,7 @@ class ServerVersionResponseDto { 'major', 'minor', 'patch', + 'prerelease', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 9fda205b9a..f6cbbba415 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -21483,12 +21483,26 @@ "maximum": 9007199254740991, "minimum": -9007199254740991, "type": "integer" + }, + "prerelease": { + "description": "Pre-release version number", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "nullable": true, + "type": "integer", + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ] } }, "required": [ "major", "minor", - "patch" + "patch", + "prerelease" ], "type": "object" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3f328088ee..0792593b04 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2074,6 +2074,8 @@ export type ServerVersionResponseDto = { minor: number; /** Patch version number */ patch: number; + /** Pre-release version number */ + prerelease: number | null; }; export type VersionCheckStateResponseDto = { /** Last check timestamp */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64cf0d3d85..d11da462f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,8 +571,8 @@ importers: specifier: ^1.6.3 version: 1.6.4 semver: - specifier: ^7.6.2 - version: 7.8.0 + specifier: ^7.8.1 + version: 7.8.1 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -11243,6 +11243,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -16300,7 +16305,7 @@ snapshots: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.8.0 + semver: 7.8.1 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -17757,7 +17762,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -18461,7 +18466,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.8.0 + semver: 7.8.1 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -19561,7 +19566,7 @@ snapshots: dot-prop: 10.1.0 env-paths: 3.0.0 json-schema-typed: 8.0.2 - semver: 7.8.0 + semver: 7.8.1 uint8array-extras: 1.5.0 config-chain@1.1.13: @@ -19733,7 +19738,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.15) postcss-modules-values: 4.0.0(postcss@8.5.15) postcss-value-parser: 4.2.0 - semver: 7.8.0 + semver: 7.8.1 optionalDependencies: webpack: 5.107.0(postcss@8.5.15) @@ -20601,7 +20606,7 @@ snapshots: find-up: 5.0.0 globals: 15.15.0 lodash.memoize: 4.1.2 - semver: 7.8.0 + semver: 7.8.1 eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3): dependencies: @@ -20624,7 +20629,7 @@ snapshots: postcss: 8.5.15 postcss-load-config: 3.1.4(postcss@8.5.15) postcss-safe-parser: 7.0.1(postcss@8.5.15) - semver: 7.8.0 + semver: 7.8.1 svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4)) optionalDependencies: svelte: 5.55.8(@typescript-eslint/types@8.59.4) @@ -21102,7 +21107,7 @@ snapshots: minimatch: 3.1.5 node-abort-controller: 3.1.1 schema-utils: 3.3.0 - semver: 7.8.0 + semver: 7.8.1 tapable: 2.3.3 typescript: 5.9.3 webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0) @@ -21538,7 +21543,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 @@ -22126,7 +22131,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.0 + semver: 7.8.1 just-compare@2.3.0: {} @@ -22412,7 +22417,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 maplibre-gl@5.24.0: dependencies: @@ -23247,7 +23252,7 @@ snapshots: node-abi@3.92.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 optional: true node-abort-controller@3.1.1: {} @@ -23288,7 +23293,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.8.0 + semver: 7.8.1 tar: 7.5.15 tinyglobby: 0.2.16 undici: 6.25.0 @@ -23526,7 +23531,7 @@ snapshots: got: 12.6.1 registry-auth-token: 5.1.1 registry-url: 6.0.1 - semver: 7.8.0 + semver: 7.8.1 package-manager-detector@1.6.0: {} @@ -23914,7 +23919,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@6.0.3) jiti: 1.21.7 postcss: 8.5.15 - semver: 7.8.0 + semver: 7.8.1 webpack: 5.107.0(postcss@8.5.15) transitivePeerDependencies: - typescript @@ -24969,12 +24974,14 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 semver@6.3.1: {} semver@7.8.0: {} + semver@7.8.1: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -25509,7 +25516,7 @@ snapshots: postcss: 8.5.15 postcss-scss: 4.0.9(postcss@8.5.15) postcss-selector-parser: 7.1.1 - semver: 7.8.0 + semver: 7.8.1 optionalDependencies: svelte: 5.55.8(@typescript-eslint/types@8.59.4) @@ -26217,7 +26224,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.8.0 + semver: 7.8.1 semver-diff: 4.0.0 xdg-basedir: 5.1.0 diff --git a/server/package.json b/server/package.json index 119a1ea603..957aa548d3 100644 --- a/server/package.json +++ b/server/package.json @@ -106,7 +106,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", - "semver": "^7.6.2", + "semver": "^7.8.1", "sharp": "^0.34.5", "sirv": "^3.0.0", "socket.io": "^4.8.1", diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 57a58e1dd7..3082fac2a1 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -1,5 +1,6 @@ import { createZodDto } from 'nestjs-zod'; import type { SemVer } from 'semver'; +import { HistoryBuilder } from 'src/decorators'; import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; @@ -61,6 +62,7 @@ const ServerVersionResponseSchema = z major: z.int().describe('Major version number'), minor: z.int().describe('Minor version number'), patch: z.int().describe('Patch version number'), + prerelease: z.int().nullable().meta(HistoryBuilder.v3().getExtensions()).describe('Pre-release version number'), }) .meta({ id: 'ServerVersionResponseDto' }); @@ -147,7 +149,12 @@ export class ServerStorageResponseDto extends createZodDto(ServerStorageResponse export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) { static fromSemVer(value: SemVer): z.infer { - return { major: value.major, minor: value.minor, patch: value.patch }; + return { + major: value.major, + minor: value.minor, + patch: value.patch, + prerelease: (value.prerelease[1] as number) ?? null, + }; } } diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 2fbe7292fa..e894e9c27c 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -22,6 +22,17 @@ describe(VersionService.name, () => { mocks.cron.update.mockResolvedValue(); }); + beforeAll(() => { + vitest.mock(import('src/constants.js'), async () => ({ + ...(await vitest.importActual('src/constants.js')), + serverVersion: new SemVer('v3.0.0'), + })); + }); + + afterAll(() => { + vitest.unmock(import('src/constants.js')); + }); + it('should work', () => { expect(sut).toBeDefined(); }); @@ -66,9 +77,10 @@ describe(VersionService.name, () => { describe('getVersion', () => { it('should respond the server version', () => { expect(sut.getVersion()).toEqual({ - major: serverVersion.major, - minor: serverVersion.minor, - patch: serverVersion.patch, + major: 3, + minor: 0, + patch: 0, + prerelease: null, }); }); }); @@ -131,6 +143,16 @@ describe(VersionService.name, () => { expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled(); }); + it('should not notify if the version is a release candidate and we are on a stable version', async () => { + mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v3.0.1-rc.1')); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VersionCheckState, { + checkedAt: expect.any(String), + releaseVersion: 'v3.0.1-rc.1', + }); + expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled(); + }); + it('should handle a version check error', async () => { mocks.serverInfo.getLatestRelease.mockRejectedValue(new Error('Version service is down')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Failed); @@ -169,21 +191,36 @@ describe(VersionService.name, () => { describe('onWebsocketConnection', () => { it('should send on_server_version client event', async () => { await sut.onWebsocketConnection({ userId: '42' }); - expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', { + major: 3, + minor: 0, + patch: 0, + prerelease: null, + }); expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1); }); it('should also send a new release notification', async () => { mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); await sut.onWebsocketConnection({ userId: '42' }); - expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', { + major: 3, + minor: 0, + patch: 0, + prerelease: null, + }); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); it('should not send a release notification when the version check is disabled', async () => { mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } }); await sut.onWebsocketConnection({ userId: '42' }); - expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', { + major: 3, + minor: 0, + patch: 0, + prerelease: null, + }); expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); }); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index ce6d6d7a6f..82190c476c 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -12,7 +12,8 @@ import { handlePromiseError } from 'src/utils/misc'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { return { - isAvailable: semver.gt(releaseVersion, serverVersion), + // can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483 + isAvailable: semver.intersects(`>${serverVersion}`, releaseVersion.toString()), checkedAt, serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion), releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)), @@ -103,7 +104,8 @@ export class VersionService extends BaseService { await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata); - if (semver.gt(releaseVersion, serverVersion)) { + // can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483 + if (semver.intersects(`>${serverVersion}`, releaseVersion.toString())) { this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); this.websocketRepository.clientBroadcast('on_new_release', asNotification(metadata)); } @@ -117,7 +119,11 @@ export class VersionService extends BaseService { @OnEvent({ name: 'WebsocketConnect' }) async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) { - this.websocketRepository.clientSend('on_server_version', userId, serverVersion); + this.websocketRepository.clientSend( + 'on_server_version', + userId, + ServerVersionResponseDto.fromSemVer(serverVersion), + ); const { newVersionCheck } = await this.getConfig({ withCache: true }); if (!newVersionCheck.enabled) { diff --git a/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte b/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte index 47b30e8454..6ac3b65a27 100644 --- a/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte +++ b/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte @@ -35,9 +35,7 @@ userInteraction.versions = versions; }); let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich'); - let version = $derived( - $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null, - ); + let version = $derived($serverVersion ? semverToName($serverVersion) : null); const getReleaseInfo = (release?: ReleaseEvent) => { if (!release || !release?.isAvailable || !authManager.user.isAdmin) { diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 9ecc7f548e..6e99f62520 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -164,23 +164,63 @@ describe('utils', () => { describe(getReleaseType.name, () => { it('should return "major" for major version changes', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major'); - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 3, minor: 2, patch: 1 })).toBe('major'); + expect( + getReleaseType( + { major: 1, minor: 0, patch: 0, prerelease: null }, + { major: 2, minor: 0, patch: 0, prerelease: null }, + ), + ).toBe('major'); + expect( + getReleaseType( + { major: 1, minor: 0, patch: 0, prerelease: null }, + { major: 3, minor: 2, patch: 1, prerelease: null }, + ), + ).toBe('major'); }); it('should return "minor" for minor version changes', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 1, patch: 0 })).toBe('minor'); - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 2, patch: 1 })).toBe('minor'); + expect( + getReleaseType( + { major: 1, minor: 0, patch: 0, prerelease: null }, + { major: 1, minor: 1, patch: 0, prerelease: null }, + ), + ).toBe('minor'); + expect( + getReleaseType( + { major: 1, minor: 0, patch: 0, prerelease: null }, + { major: 1, minor: 2, patch: 1, prerelease: null }, + ), + ).toBe('minor'); }); it('should return "patch" for patch version changes', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 1 })).toBe('patch'); - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 5 })).toBe('patch'); + expect( + getReleaseType( + { major: 1, minor: 0, patch: 0, prerelease: null }, + { major: 1, minor: 0, patch: 1, prerelease: null }, + ), + ).toBe('patch'); + expect( + getReleaseType( + { major: 1, minor: 0, patch: 0, prerelease: null }, + { major: 1, minor: 0, patch: 5, prerelease: null }, + ), + ).toBe('patch'); }); it('should return "none" for matching versions', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 0 })).toBe('none'); - expect(getReleaseType({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe('none'); + expect( + getReleaseType( + { major: 1, minor: 0, patch: 0, prerelease: null }, + { major: 1, minor: 0, patch: 0, prerelease: null }, + ), + ).toBe('none'); + expect( + getReleaseType( + { major: 1, minor: 2, patch: 3, prerelease: null }, + { major: 1, minor: 2, patch: 3, prerelease: null }, + ), + ).toBe('none'); }); }); }); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 397e32e136..df5da2bc06 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -430,7 +430,8 @@ export const getReleaseType = ( return 'none'; }; -export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; +export const semverToName = ({ major, minor, patch, prerelease }: ServerVersionResponseDto) => + `v${major}.${minor}.${patch}${prerelease ? `-rc.${prerelease}` : ''}`; export const withoutIcons = (actions: ActionItem[]): ActionItem[] => actions.map((action) => ({ ...action, icon: undefined }));