feat: move version checks to our own infrastructure (#27450)

This commit is contained in:
Zack Pollard
2026-04-02 23:32:26 +01:00
committed by GitHub
parent adb6b39eec
commit db0f86c749
61 changed files with 104 additions and 72 deletions
+3
View File
@@ -37,6 +37,7 @@ import { CliService } from 'src/services/cli.service';
import { DatabaseBackupService } from 'src/services/database-backup.service';
import { QueueService } from 'src/services/queue.service';
import { getKyselyConfig } from 'src/utils/database';
import { configureUserAgent } from 'src/utils/fetch';
const common = [...repositories, ...services, GlobalExceptionFilter];
@@ -60,6 +61,8 @@ const commonImports = [
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
configureUserAgent();
export class BaseModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
@@ -17,6 +17,11 @@ export interface GitHubRelease {
body: string;
}
export interface VersionResponse {
version: string;
published_at: string;
}
export interface ServerBuildVersions {
nodejs: string;
ffmpeg: string;
@@ -59,17 +64,17 @@ export class ServerInfoRepository {
this.logger.setContext(ServerInfoRepository.name);
}
async getGitHubRelease(): Promise<GitHubRelease> {
async getLatestRelease(): Promise<VersionResponse> {
try {
const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest');
const response = await fetch('https://version.immich.cloud/version');
if (!response.ok) {
throw new Error(`GitHub API request failed with status ${response.status}: ${await response.text()}`);
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
}
return response.json();
} catch (error) {
throw new Error('Failed to fetch GitHub release', { cause: error });
throw new Error('Failed to fetch latest release', { cause: error });
}
}
+6 -11
View File
@@ -8,14 +8,9 @@ import { mockEnvData } from 'test/repositories/config.repository.mock';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const mockRelease = (version: string) => ({
id: 1,
url: 'https://api.github.com/repos/owner/repo/releases/1',
tag_name: version,
name: 'Release 1000',
created_at: DateTime.utc().toISO(),
const mockVersionResponse = (version: string) => ({
version,
published_at: DateTime.utc().toISO(),
body: '',
});
describe(VersionService.name, () => {
@@ -101,7 +96,7 @@ describe(VersionService.name, () => {
});
it('should run if it has been > 60 minutes', async () => {
mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0'));
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v100.0.0'));
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(),
releaseVersion: '1.0.0',
@@ -113,7 +108,7 @@ describe(VersionService.name, () => {
});
it('should not notify if the version is equal', async () => {
mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString()));
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString()));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success);
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VersionCheckState, {
checkedAt: expect.any(String),
@@ -122,8 +117,8 @@ describe(VersionService.name, () => {
expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled();
});
it('should handle a github error', async () => {
mocks.serverInfo.getGitHubRelease.mockRejectedValue(new Error('GitHub is down'));
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);
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled();
+1 -2
View File
@@ -91,8 +91,7 @@ export class VersionService extends BaseService {
}
}
const { tag_name: releaseVersion, published_at: publishedAt } =
await this.serverInfoRepository.getGitHubRelease();
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease();
const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata);
+18
View File
@@ -0,0 +1,18 @@
import { serverVersion } from 'src/constants';
import { configureUserAgent } from 'src/utils/fetch';
describe('fetch', () => {
it('should set the default user-agent header', async () => {
const spy = vi.fn().mockResolvedValue(new Response());
const original = globalThis.fetch;
globalThis.fetch = spy;
configureUserAgent();
await globalThis.fetch('http://test.local');
const headers: Headers = spy.mock.calls[0][1].headers;
expect(headers.get('User-Agent')).toBe(`immich-server/${serverVersion}`);
globalThis.fetch = original;
});
});
+12
View File
@@ -0,0 +1,12 @@
import { serverVersion } from 'src/constants';
export function configureUserAgent() {
const originalFetch = globalThis.fetch;
globalThis.fetch = (input, init) => {
const headers = new Headers(init?.headers);
if (!headers.has('User-Agent')) {
headers.set('User-Agent', `immich-server/${serverVersion}`);
}
return originalFetch(input, { ...init, headers });
};
}