[codex] add automatic update-center reminders (#333)

* add automatic update-center reminders

* fix update-center reminder review issues
This commit is contained in:
Cita
2026-03-30 22:37:24 +08:00
committed by GitHub
parent 26f9717ad7
commit 9eb33df6d4
21 changed files with 1009 additions and 176 deletions
+5 -1
View File
@@ -33,7 +33,8 @@ import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js';
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
import { startSiteAnnouncementPolling, stopSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
import { startUpdateCenterPolling, stopUpdateCenterPolling } from './services/updateCenterPollingService.js';
import { reloadBackupWebdavScheduler } from './services/backupService.js';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
@@ -483,6 +484,7 @@ if (existsSync(webDir)) {
await startScheduler();
await reloadBackupWebdavScheduler();
startSiteAnnouncementPolling();
startUpdateCenterPolling();
try {
await startOAuthLoopbackCallbackServers();
} catch (error) {
@@ -491,6 +493,8 @@ try {
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
startProxyFileRetentionService();
app.addHook('onClose', async () => {
stopSiteAnnouncementPolling();
stopUpdateCenterPolling();
stopProxyFileRetentionService();
stopProxyLogRetentionService();
await stopOAuthLoopbackCallbackServers();
@@ -200,6 +200,15 @@ describe('update center routes', () => {
},
],
},
runtime: {
lastCheckedAt: null,
lastCheckError: null,
lastResolvedSource: null,
lastResolvedDisplayVersion: null,
lastResolvedCandidateKey: null,
lastNotifiedCandidateKey: null,
lastNotifiedAt: null,
},
});
});
+6 -78
View File
@@ -4,17 +4,10 @@ import { config as runtimeConfig } from '../../config.js';
import {
appendBackgroundTaskLog,
getBackgroundTask,
listBackgroundTasks,
startBackgroundTask,
subscribeToBackgroundTaskLogs,
} from '../../services/backgroundTaskService.js';
import {
fetchLatestDockerHubTag,
fetchLatestStableGitHubRelease,
getCurrentRuntimeVersion,
type UpdateCenterVersionCandidate,
type UpdateCenterVersionSource,
} from '../../services/updateCenterVersionService.js';
import { type UpdateCenterVersionSource } from '../../services/updateCenterVersionService.js';
import {
getUpdateCenterDeployBlockMessage,
normalizeUpdateCenterTargetDigest,
@@ -27,10 +20,14 @@ import {
type UpdateCenterConfig,
} from '../../services/updateCenterConfigService.js';
import {
getUpdateCenterHelperStatus,
streamUpdateCenterDeploy,
streamUpdateCenterRollback,
} from '../../services/updateCenterHelperClient.js';
import { buildUpdateCenterStatus } from '../../services/updateCenterStatusService.js';
import {
UPDATE_CENTER_DEPLOY_DEDUPE_KEY,
UPDATE_CENTER_DEPLOY_TASK_TYPE,
} from '../../services/updateCenterTaskConstants.js';
type DeployBody = {
source?: UpdateCenterVersionSource;
@@ -43,9 +40,6 @@ type RollbackBody = {
targetRevision?: string;
};
const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = 'update-center.deploy';
function getUpdateCenterHelperToken(): string {
return String(
runtimeConfig.deployHelperToken
@@ -69,77 +63,11 @@ function assertDeployableConfig(config: UpdateCenterConfig) {
if (!config.imageRepository) throw new Error('imageRepository is required');
}
function getDeployTasks() {
return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE);
}
function summarizeHelperError(error: unknown) {
if (error instanceof Error && error.message) return error.message;
return String(error || 'unknown helper error');
}
async function settleOptional<T>(enabled: boolean, loader: () => Promise<T>): Promise<{
value: T | null;
error: string | null;
}> {
if (!enabled) {
return {
value: null,
error: null,
};
}
try {
return {
value: await loader(),
error: null,
};
} catch (error) {
return {
value: null,
error: summarizeHelperError(error),
};
}
}
async function buildUpdateCenterStatus() {
const config = await loadUpdateCenterConfig();
const helperToken = getUpdateCenterHelperToken();
const [githubLookup, dockerLookup, helperLookup] = await Promise.all([
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()),
settleOptional(!!config.helperBaseUrl, async () => {
if (!helperToken) {
throw new Error('DEPLOY_HELPER_TOKEN is required');
}
return await getUpdateCenterHelperStatus(config, helperToken);
}),
]);
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null;
const helper: Record<string, unknown> = helperLookup.value || {
ok: false,
healthy: false,
error: helperLookup.error,
};
const tasks = getDeployTasks();
const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null;
const lastFinishedTask = tasks.find((task) => task.status === 'succeeded' || task.status === 'failed') || null;
return {
currentVersion: getCurrentRuntimeVersion(),
config,
githubRelease,
dockerHubTag,
helper,
runningTask,
lastFinishedTask,
};
}
function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: string) => void } }, event: string, data: unknown) {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
@@ -0,0 +1,164 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const {
buildUpdateCenterStatusMock,
sendNotificationMock,
} = vi.hoisted(() => ({
buildUpdateCenterStatusMock: vi.fn(),
sendNotificationMock: vi.fn(),
}));
vi.mock('./updateCenterStatusService.js', () => ({
buildUpdateCenterStatus: (...args: unknown[]) => buildUpdateCenterStatusMock(...args),
}));
vi.mock('./notifyService.js', () => ({
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
}));
type DbModule = typeof import('../db/index.js');
type PollingModule = typeof import('./updateCenterPollingService.js');
type RuntimeStateModule = typeof import('./updateCenterRuntimeStateService.js');
describe('updateCenterPollingService', () => {
let dataDir = '';
let db: DbModule['db'];
let schema: DbModule['schema'];
let closeDbConnections: DbModule['closeDbConnections'];
let startUpdateCenterPolling: PollingModule['startUpdateCenterPolling'];
let stopUpdateCenterPolling: PollingModule['stopUpdateCenterPolling'];
let loadUpdateCenterRuntimeState: RuntimeStateModule['loadUpdateCenterRuntimeState'];
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-update-center-polling-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const pollingModule = await import('./updateCenterPollingService.js');
const runtimeStateModule = await import('./updateCenterRuntimeStateService.js');
db = dbModule.db;
schema = dbModule.schema;
closeDbConnections = dbModule.closeDbConnections;
startUpdateCenterPolling = pollingModule.startUpdateCenterPolling;
stopUpdateCenterPolling = pollingModule.stopUpdateCenterPolling;
loadUpdateCenterRuntimeState = runtimeStateModule.loadUpdateCenterRuntimeState;
});
beforeEach(async () => {
vi.useFakeTimers();
buildUpdateCenterStatusMock.mockReset();
sendNotificationMock.mockReset();
await db.delete(schema.events).run();
await db.delete(schema.settings).run();
});
afterEach(() => {
stopUpdateCenterPolling();
vi.useRealTimers();
});
afterAll(async () => {
if (typeof closeDbConnections === 'function') {
await closeDbConnections();
}
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('runs immediately, writes one event, and only notifies once for the same candidate', async () => {
buildUpdateCenterStatusMock.mockResolvedValue({
currentVersion: '1.2.3',
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
tagName: 'v1.3.0',
},
dockerHubTag: null,
helper: {
imageTag: '1.2.3',
},
});
startUpdateCenterPolling(60_000);
await vi.advanceTimersByTimeAsync(0);
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
expect(sendNotificationMock.mock.calls[0]?.[0]).toContain('更新中心');
expect(sendNotificationMock.mock.calls[0]?.[1]).toContain('1.3.0');
let events = await db.select().from(schema.events).all();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: 'status',
relatedType: 'update_center',
level: 'info',
});
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
lastCheckError: null,
lastResolvedSource: 'github-release',
lastResolvedCandidateKey: 'github-release:v1.3.0',
lastNotifiedCandidateKey: 'github-release:v1.3.0',
}));
await vi.advanceTimersByTimeAsync(60_000);
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
events = await db.select().from(schema.events).all();
expect(events).toHaveLength(1);
});
it('stores the latest check error without creating events or notifications when the background check fails', async () => {
buildUpdateCenterStatusMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
startUpdateCenterPolling(60_000);
await vi.advanceTimersByTimeAsync(0);
expect(sendNotificationMock).not.toHaveBeenCalled();
expect(await db.select().from(schema.events).all()).toEqual([]);
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
lastCheckError: 'GitHub releases lookup timed out',
lastNotifiedCandidateKey: null,
}));
});
it('persists the notified candidate even when the downstream notification send fails', async () => {
buildUpdateCenterStatusMock.mockImplementation(async () => ({
currentVersion: '1.2.3',
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
tagName: 'v1.3.0',
},
dockerHubTag: null,
helper: {
imageTag: '1.2.3',
},
runtime: await loadUpdateCenterRuntimeState(),
}));
sendNotificationMock.mockRejectedValue(new Error('notification downstream failed'));
startUpdateCenterPolling(60_000);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
expect(await db.select().from(schema.events).all()).toHaveLength(1);
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
lastCheckError: null,
lastResolvedCandidateKey: 'github-release:v1.3.0',
lastNotifiedCandidateKey: 'github-release:v1.3.0',
lastNotifiedAt: expect.any(String),
}));
});
});
@@ -0,0 +1,104 @@
import { db, schema } from '../db/index.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
import { sendNotification } from './notifyService.js';
import { buildUpdateCenterStatus } from './updateCenterStatusService.js';
import { loadUpdateCenterRuntimeState, saveUpdateCenterRuntimeState } from './updateCenterRuntimeStateService.js';
import { resolveUpdateReminderCandidate } from './updateCenterReminderService.js';
const DEFAULT_UPDATE_CENTER_INTERVAL_MS = 15 * 60 * 1000;
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let syncRunning = false;
function summarizeError(error: unknown) {
if (error instanceof Error && error.message) return error.message;
return String(error || 'unknown error');
}
function buildReminderEvent(candidate: ReturnType<typeof resolveUpdateReminderCandidate>) {
if (!candidate) return null;
const title = candidate.kind === 'new-digest'
? '更新中心发现新 digest'
: '更新中心发现新版本';
const message = candidate.kind === 'new-digest'
? `检测到 ${candidate.displayVersion} 指向了新的镜像 digest,可前往更新中心查看并手动部署。`
: `检测到 ${candidate.displayVersion} 已可部署,可前往更新中心查看并手动部署。`;
return {
title,
message,
};
}
async function runSyncOnce() {
if (syncRunning) return;
syncRunning = true;
const checkedAt = formatUtcSqlDateTime(new Date());
try {
const status = await buildUpdateCenterStatus();
const previousRuntime = status.runtime || await loadUpdateCenterRuntimeState();
const candidate = resolveUpdateReminderCandidate({
currentVersion: status.currentVersion,
helper: status.helper as { imageTag?: string | null; imageDigest?: string | null } | null,
githubRelease: status.githubRelease,
dockerHubTag: status.dockerHubTag,
});
const nextRuntime = {
...previousRuntime,
lastCheckedAt: checkedAt,
lastCheckError: null,
lastResolvedSource: candidate?.source || null,
lastResolvedDisplayVersion: candidate?.displayVersion || null,
lastResolvedCandidateKey: candidate?.candidateKey || null,
};
if (candidate && candidate.candidateKey !== previousRuntime.lastNotifiedCandidateKey) {
const reminderEvent = buildReminderEvent(candidate);
if (reminderEvent) {
await db.insert(schema.events).values({
type: 'status',
title: reminderEvent.title,
message: reminderEvent.message,
level: 'info',
relatedType: 'update_center',
createdAt: checkedAt,
}).run();
nextRuntime.lastNotifiedCandidateKey = candidate.candidateKey;
nextRuntime.lastNotifiedAt = checkedAt;
await saveUpdateCenterRuntimeState(nextRuntime);
await sendNotification(reminderEvent.title, reminderEvent.message, 'info', {
bypassThrottle: true,
});
}
}
await saveUpdateCenterRuntimeState(nextRuntime);
} catch (error) {
const previousRuntime = await loadUpdateCenterRuntimeState();
await saveUpdateCenterRuntimeState({
...previousRuntime,
lastCheckedAt: checkedAt,
lastCheckError: summarizeError(error),
});
} finally {
syncRunning = false;
}
}
export function startUpdateCenterPolling(intervalMs = DEFAULT_UPDATE_CENTER_INTERVAL_MS) {
stopUpdateCenterPolling();
pollingTimer = setInterval(() => {
void runSyncOnce();
}, Math.max(10_000, intervalMs));
pollingTimer.unref?.();
void runSyncOnce();
return { intervalMs: Math.max(10_000, intervalMs) };
}
export function stopUpdateCenterPolling() {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
}
@@ -0,0 +1,6 @@
export type {
UpdateHelperRuntimeLike,
UpdateReminderCandidate,
UpdateVersionCandidateLike,
} from '../shared/updateCenterReminder.js';
export { resolveUpdateReminderCandidate } from '../shared/updateCenterReminder.js';
@@ -0,0 +1,81 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
type DbModule = typeof import('../db/index.js');
type RuntimeStateModule = typeof import('./updateCenterRuntimeStateService.js');
describe('updateCenterRuntimeStateService', () => {
let dataDir = '';
let db: DbModule['db'];
let schema: DbModule['schema'];
let closeDbConnections: DbModule['closeDbConnections'];
let loadUpdateCenterRuntimeState: RuntimeStateModule['loadUpdateCenterRuntimeState'];
let saveUpdateCenterRuntimeState: RuntimeStateModule['saveUpdateCenterRuntimeState'];
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-update-center-runtime-state-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const runtimeStateModule = await import('./updateCenterRuntimeStateService.js');
db = dbModule.db;
schema = dbModule.schema;
closeDbConnections = dbModule.closeDbConnections;
loadUpdateCenterRuntimeState = runtimeStateModule.loadUpdateCenterRuntimeState;
saveUpdateCenterRuntimeState = runtimeStateModule.saveUpdateCenterRuntimeState;
});
beforeEach(async () => {
await db.delete(schema.settings).run();
});
afterAll(async () => {
if (typeof closeDbConnections === 'function') {
await closeDbConnections();
}
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('returns an empty default state when nothing has been persisted yet', async () => {
await expect(loadUpdateCenterRuntimeState()).resolves.toEqual({
lastCheckedAt: null,
lastCheckError: null,
lastResolvedSource: null,
lastResolvedDisplayVersion: null,
lastResolvedCandidateKey: null,
lastNotifiedCandidateKey: null,
lastNotifiedAt: null,
});
});
it('persists and reloads reminder runtime metadata', async () => {
await saveUpdateCenterRuntimeState({
lastCheckedAt: '2026-03-30 20:30:00',
lastCheckError: null,
lastResolvedSource: 'docker-hub-tag',
lastResolvedDisplayVersion: 'latest @ sha256:efb2ee655386',
lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
lastNotifiedAt: '2026-03-30 20:31:00',
});
await expect(loadUpdateCenterRuntimeState()).resolves.toEqual({
lastCheckedAt: '2026-03-30 20:30:00',
lastCheckError: null,
lastResolvedSource: 'docker-hub-tag',
lastResolvedDisplayVersion: 'latest @ sha256:efb2ee655386',
lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
lastNotifiedAt: '2026-03-30 20:31:00',
});
});
});
@@ -0,0 +1,72 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
import type { UpdateCenterVersionSource } from './updateCenterVersionService.js';
export type UpdateCenterRuntimeState = {
lastCheckedAt: string | null;
lastCheckError: string | null;
lastResolvedSource: UpdateCenterVersionSource | null;
lastResolvedDisplayVersion: string | null;
lastResolvedCandidateKey: string | null;
lastNotifiedCandidateKey: string | null;
lastNotifiedAt: string | null;
};
export const UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY = 'update_center_runtime_state_v1';
export function getDefaultUpdateCenterRuntimeState(): UpdateCenterRuntimeState {
return {
lastCheckedAt: null,
lastCheckError: null,
lastResolvedSource: null,
lastResolvedDisplayVersion: null,
lastResolvedCandidateKey: null,
lastNotifiedCandidateKey: null,
lastNotifiedAt: null,
};
}
function normalizeNullableString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || null;
}
function normalizeNullableSource(value: unknown): UpdateCenterVersionSource | null {
return value === 'docker-hub-tag' || value === 'github-release' ? value : null;
}
export function normalizeUpdateCenterRuntimeState(input: unknown): UpdateCenterRuntimeState {
const defaults = getDefaultUpdateCenterRuntimeState();
const record = input && typeof input === 'object' ? input as Record<string, unknown> : {};
return {
lastCheckedAt: normalizeNullableString(record.lastCheckedAt) ?? defaults.lastCheckedAt,
lastCheckError: normalizeNullableString(record.lastCheckError) ?? defaults.lastCheckError,
lastResolvedSource: normalizeNullableSource(record.lastResolvedSource) ?? defaults.lastResolvedSource,
lastResolvedDisplayVersion: normalizeNullableString(record.lastResolvedDisplayVersion) ?? defaults.lastResolvedDisplayVersion,
lastResolvedCandidateKey: normalizeNullableString(record.lastResolvedCandidateKey) ?? defaults.lastResolvedCandidateKey,
lastNotifiedCandidateKey: normalizeNullableString(record.lastNotifiedCandidateKey) ?? defaults.lastNotifiedCandidateKey,
lastNotifiedAt: normalizeNullableString(record.lastNotifiedAt) ?? defaults.lastNotifiedAt,
};
}
export async function loadUpdateCenterRuntimeState(): Promise<UpdateCenterRuntimeState> {
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY)).get();
if (!row?.value) {
return getDefaultUpdateCenterRuntimeState();
}
try {
return normalizeUpdateCenterRuntimeState(JSON.parse(row.value));
} catch {
return getDefaultUpdateCenterRuntimeState();
}
}
export async function saveUpdateCenterRuntimeState(input: unknown): Promise<UpdateCenterRuntimeState> {
const next = normalizeUpdateCenterRuntimeState(input);
await upsertSetting(UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY, next);
return next;
}
@@ -0,0 +1,98 @@
import { config as runtimeConfig } from '../config.js';
import { listBackgroundTasks } from './backgroundTaskService.js';
import {
fetchLatestDockerHubTag,
fetchLatestStableGitHubRelease,
getCurrentRuntimeVersion,
type UpdateCenterVersionCandidate,
} from './updateCenterVersionService.js';
import {
loadUpdateCenterConfig,
} from './updateCenterConfigService.js';
import {
getUpdateCenterHelperStatus,
} from './updateCenterHelperClient.js';
import { loadUpdateCenterRuntimeState } from './updateCenterRuntimeStateService.js';
import { UPDATE_CENTER_DEPLOY_TASK_TYPE } from './updateCenterTaskConstants.js';
function getUpdateCenterHelperToken(): string {
return String(
runtimeConfig.deployHelperToken
|| process.env.DEPLOY_HELPER_TOKEN
|| process.env.UPDATE_CENTER_HELPER_TOKEN
|| '',
).trim();
}
function summarizeHelperError(error: unknown) {
if (error instanceof Error && error.message) return error.message;
return String(error || 'unknown helper error');
}
async function settleOptional<T>(enabled: boolean, loader: () => Promise<T>): Promise<{
value: T | null;
error: string | null;
}> {
if (!enabled) {
return {
value: null,
error: null,
};
}
try {
return {
value: await loader(),
error: null,
};
} catch (error) {
return {
value: null,
error: summarizeHelperError(error),
};
}
}
function getDeployTasks() {
return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE);
}
export async function buildUpdateCenterStatus() {
const config = await loadUpdateCenterConfig();
const helperToken = getUpdateCenterHelperToken();
const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()),
settleOptional(!!config.helperBaseUrl, async () => {
if (!helperToken) {
throw new Error('DEPLOY_HELPER_TOKEN is required');
}
return await getUpdateCenterHelperStatus(config, helperToken);
}),
loadUpdateCenterRuntimeState(),
]);
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null;
const helper: Record<string, unknown> = helperLookup.value || {
ok: false,
healthy: false,
error: helperLookup.error,
};
const tasks = getDeployTasks();
const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null;
const lastFinishedTask = tasks.find((task) => task.status === 'succeeded' || task.status === 'failed') || null;
return {
currentVersion: getCurrentRuntimeVersion(),
config,
githubRelease,
dockerHubTag,
helper,
runningTask,
lastFinishedTask,
runtime,
};
}
@@ -0,0 +1,2 @@
export const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
export const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = UPDATE_CENTER_DEPLOY_TASK_TYPE;
+153
View File
@@ -0,0 +1,153 @@
export type UpdateVersionCandidateLike = {
normalizedVersion?: string | null;
displayVersion?: string | null;
tagName?: string | null;
digest?: string | null;
};
export type UpdateHelperRuntimeLike = {
imageTag?: string | null;
imageDigest?: string | null;
};
export type UpdateReminderCandidate = {
source: 'github-release' | 'docker-hub-tag';
kind: 'new-version' | 'new-digest';
candidateKey: string;
displayVersion: string;
tagName: string;
digest: string | null;
};
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
function normalizeString(value?: string | null): string {
return String(value || '').trim();
}
function normalizeDigest(value?: string | null): string {
const digest = normalizeString(value);
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
}
function normalizeStableTag(value?: string | null): string {
return normalizeStableVersion(value);
}
function hasSameStableTag(left?: string | null, right?: string | null): boolean {
const normalizedLeft = normalizeStableTag(left);
const normalizedRight = normalizeStableTag(right);
return !!normalizedLeft && normalizedLeft === normalizedRight;
}
export function normalizeStableVersion(value?: string | null): string {
const raw = normalizeString(value);
if (!raw) return '';
const match = raw.match(STABLE_SEMVER_PATTERN);
if (!match) return '';
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3], 10),
].join('.');
}
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
const normalizedLeft = normalizeStableVersion(left);
const normalizedRight = normalizeStableVersion(right);
if (!normalizedLeft || !normalizedRight) return null;
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] === rightParts[index]) continue;
return leftParts[index] < rightParts[index] ? -1 : 1;
}
return 0;
}
export function isSameImageTarget(
current: UpdateHelperRuntimeLike | null | undefined,
target: { tag?: string | null; digest?: string | null },
): boolean {
const currentDigest = normalizeDigest(current?.imageDigest);
const targetDigest = normalizeDigest(target.digest);
if (currentDigest && targetDigest) {
return currentDigest === targetDigest;
}
return hasSameStableTag(current?.imageTag, target.tag);
}
export function buildUpdateReminderCandidateKey(
source: 'github-release' | 'docker-hub-tag',
candidate: { tagName?: string | null; digest?: string | null },
): string {
const tagName = normalizeString(candidate.tagName);
const digest = normalizeDigest(candidate.digest);
if (!tagName) return '';
if (source === 'docker-hub-tag' && digest) {
return `${source}:${tagName}@${digest}`;
}
return `${source}:${tagName}`;
}
export function resolveUpdateReminderCandidate(input: {
currentVersion?: string | null;
helper: UpdateHelperRuntimeLike | null | undefined;
githubRelease: UpdateVersionCandidateLike | null | undefined;
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
}): UpdateReminderCandidate | null {
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
if (githubTag) {
const githubTargetVersion = githubCandidateVersion || githubTag;
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
return {
source: 'github-release',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
tagName: githubTag,
digest: null,
};
}
}
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
if (!dockerTag) return null;
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
return null;
}
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
if (dockerVersionCompare === -1) {
return {
source: 'docker-hub-tag',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest || null,
};
}
const helperDigest = normalizeDigest(input.helper?.imageDigest);
if (dockerDigest && helperDigest && hasSameStableTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
return {
source: 'docker-hub-tag',
kind: 'new-digest',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest,
};
}
return null;
}