forked from boos/BoosAPI
Let operators deploy recent dev Docker tags without manual lookup
The update center already allowed manual Docker tag deploys, but operators still had to know and paste dev or branch tags by hand. This change keeps the stable Docker Hub candidate as the main path while automatically surfacing the most recent non-stable tags and their digests for one-click deployment. Constraint: Stable Docker Hub discovery must remain the default candidate path Constraint: Existing deploy API and helper protocol should stay unchanged Rejected: Promote dev tags into the primary stable candidate slot | would blur stable and experimental release semantics Rejected: Add a brand new backend deploy endpoint for recent tags | existing targetTag/targetDigest deploy path already covers the need Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep stable and recent non-stable Docker tag discovery distinct unless product semantics deliberately change Tested: Focused vitest for update-center services/routes/UI, npm run typecheck, npm run repo:drift-check, npm run build:web, npm run docs:build, git diff --check Not-tested: Full npm test remains red because tmp/sub2api frontend suites are still broken in this repo snapshot
This commit is contained in:
@@ -9,13 +9,13 @@ import { waitForBackgroundTaskToReachTerminalState } from '../../test-fixtures/b
|
||||
|
||||
const {
|
||||
fetchLatestStableGitHubReleaseMock,
|
||||
fetchLatestDockerHubTagMock,
|
||||
fetchDockerHubTagCandidatesMock,
|
||||
getUpdateCenterHelperStatusMock,
|
||||
streamUpdateCenterDeployMock,
|
||||
streamUpdateCenterRollbackMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
||||
fetchLatestDockerHubTagMock: vi.fn(),
|
||||
fetchDockerHubTagCandidatesMock: vi.fn(),
|
||||
getUpdateCenterHelperStatusMock: vi.fn(),
|
||||
streamUpdateCenterDeployMock: vi.fn(),
|
||||
streamUpdateCenterRollbackMock: vi.fn(),
|
||||
@@ -26,7 +26,7 @@ vi.mock('../../services/updateCenterVersionService.js', async () => {
|
||||
return {
|
||||
...actual,
|
||||
fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args),
|
||||
fetchLatestDockerHubTag: (...args: unknown[]) => fetchLatestDockerHubTagMock(...args),
|
||||
fetchDockerHubTagCandidates: (...args: unknown[]) => fetchDockerHubTagCandidatesMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('update center routes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockReset();
|
||||
fetchLatestDockerHubTagMock.mockReset();
|
||||
fetchDockerHubTagCandidatesMock.mockReset();
|
||||
getUpdateCenterHelperStatusMock.mockReset();
|
||||
streamUpdateCenterDeployMock.mockReset();
|
||||
streamUpdateCenterRollbackMock.mockReset();
|
||||
@@ -139,6 +139,18 @@ describe('update center routes', () => {
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
url: null,
|
||||
} as const;
|
||||
const dockerHubRecentTags = [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev',
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-29T12:54:35.591877Z',
|
||||
url: null,
|
||||
},
|
||||
] as const;
|
||||
const helperStatus = {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
@@ -161,7 +173,10 @@ describe('update center routes', () => {
|
||||
],
|
||||
} as const;
|
||||
fetchLatestStableGitHubReleaseMock.mockResolvedValue(githubRelease);
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue(dockerHubTag);
|
||||
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||
primary: dockerHubTag,
|
||||
recentNonStable: dockerHubRecentTags,
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue(helperStatus);
|
||||
|
||||
const saveResponse = await app.inject({
|
||||
@@ -216,6 +231,13 @@ describe('update center routes', () => {
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
@@ -242,11 +264,14 @@ describe('update center routes', () => {
|
||||
|
||||
it('returns partial status when a single version source lookup fails', async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.3.1',
|
||||
normalizedVersion: '1.3.1',
|
||||
url: null,
|
||||
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||
primary: {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.3.1',
|
||||
normalizedVersion: '1.3.1',
|
||||
url: null,
|
||||
},
|
||||
recentNonStable: [],
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -426,6 +451,18 @@ describe('update center routes', () => {
|
||||
publishedAt: '2026-03-31T09:00:00Z',
|
||||
url: null,
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev',
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-31T09:05:00Z',
|
||||
url: null,
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
@@ -454,6 +491,12 @@ describe('update center routes', () => {
|
||||
dockerHubTag: {
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
},
|
||||
@@ -462,7 +505,7 @@ describe('update center routes', () => {
|
||||
},
|
||||
});
|
||||
expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled();
|
||||
expect(fetchLatestDockerHubTagMock).not.toHaveBeenCalled();
|
||||
expect(fetchDockerHubTagCandidatesMock).not.toHaveBeenCalled();
|
||||
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -477,15 +520,29 @@ describe('update center routes', () => {
|
||||
publishedAt: '2026-03-31T10:00:00Z',
|
||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1',
|
||||
});
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'latest',
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
displayVersion: 'latest @ sha256:dddddddddddd',
|
||||
publishedAt: '2026-03-31T10:00:00Z',
|
||||
url: null,
|
||||
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||
primary: {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'latest',
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
displayVersion: 'latest @ sha256:dddddddddddd',
|
||||
publishedAt: '2026-03-31T10:00:00Z',
|
||||
url: null,
|
||||
},
|
||||
recentNonStable: [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev-20260417-f67ade2',
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
tagName: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:abababababababababababababababababababababababababababababababab',
|
||||
displayVersion: 'dev-20260417-f67ade2 @ sha256:abababababab',
|
||||
publishedAt: '2026-03-31T10:05:00Z',
|
||||
url: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -512,6 +569,12 @@ describe('update center routes', () => {
|
||||
dockerHubTag: {
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:abababababababababababababababababababababababababababababababab',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
revision: '13',
|
||||
},
|
||||
@@ -520,7 +583,7 @@ describe('update center routes', () => {
|
||||
},
|
||||
});
|
||||
expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchLatestDockerHubTagMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchDockerHubTagCandidatesMock).toHaveBeenCalledTimes(1);
|
||||
expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1);
|
||||
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
|
||||
lastResolvedSource: 'github-release',
|
||||
@@ -532,6 +595,11 @@ describe('update center routes', () => {
|
||||
dockerHubTag: expect.objectContaining({
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
}),
|
||||
dockerHubRecentTags: [
|
||||
expect.objectContaining({
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
}),
|
||||
],
|
||||
helper: expect.objectContaining({
|
||||
revision: '13',
|
||||
}),
|
||||
|
||||
@@ -97,6 +97,7 @@ describe('updateCenterPollingService', () => {
|
||||
publishedAt: '2026-03-31T12:00:00Z',
|
||||
},
|
||||
dockerHubTag: null,
|
||||
dockerHubRecentTags: [],
|
||||
helper: {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
@@ -115,6 +116,7 @@ describe('updateCenterPollingService', () => {
|
||||
currentVersion: '1.2.3',
|
||||
githubRelease: runtime.statusSnapshot.githubRelease,
|
||||
dockerHubTag: null,
|
||||
dockerHubRecentTags: [],
|
||||
helper: runtime.statusSnapshot.helper,
|
||||
runtime,
|
||||
},
|
||||
@@ -151,15 +153,16 @@ describe('updateCenterPollingService', () => {
|
||||
lastResolvedSource: 'github-release',
|
||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||
statusSnapshot: {
|
||||
statusSnapshot: expect.objectContaining({
|
||||
githubRelease: expect.objectContaining({
|
||||
normalizedVersion: '1.3.0',
|
||||
}),
|
||||
dockerHubTag: null,
|
||||
dockerHubRecentTags: [],
|
||||
helper: expect.objectContaining({
|
||||
imageTag: '1.2.3',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
@@ -205,6 +208,7 @@ describe('updateCenterPollingService', () => {
|
||||
publishedAt: '2026-03-31T12:01:00Z',
|
||||
},
|
||||
dockerHubTag: null,
|
||||
dockerHubRecentTags: [],
|
||||
helper: {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
@@ -223,6 +227,7 @@ describe('updateCenterPollingService', () => {
|
||||
currentVersion: '1.2.3',
|
||||
githubRelease: runtime.statusSnapshot.githubRelease,
|
||||
dockerHubTag: null,
|
||||
dockerHubRecentTags: [],
|
||||
helper: runtime.statusSnapshot.helper,
|
||||
runtime,
|
||||
},
|
||||
@@ -251,15 +256,16 @@ describe('updateCenterPollingService', () => {
|
||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedAt: expect.any(String),
|
||||
statusSnapshot: {
|
||||
statusSnapshot: expect.objectContaining({
|
||||
githubRelease: expect.objectContaining({
|
||||
normalizedVersion: '1.3.0',
|
||||
}),
|
||||
dockerHubTag: null,
|
||||
dockerHubRecentTags: [],
|
||||
helper: expect.objectContaining({
|
||||
imageTag: '1.2.3',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,18 @@ describe('updateCenterRuntimeStateService', () => {
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-30T20:30:00Z',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev',
|
||||
normalizedVersion: 'dev',
|
||||
url: null,
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T20:35:00Z',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
@@ -142,6 +154,18 @@ describe('updateCenterRuntimeStateService', () => {
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-30T20:30:00Z',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev',
|
||||
normalizedVersion: 'dev',
|
||||
url: null,
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T20:35:00Z',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { UpdateCenterVersionCandidate, UpdateCenterVersionSource } from './
|
||||
export type UpdateCenterStatusSnapshot = {
|
||||
githubRelease: UpdateCenterVersionCandidate | null;
|
||||
dockerHubTag: UpdateCenterVersionCandidate | null;
|
||||
dockerHubRecentTags: UpdateCenterVersionCandidate[];
|
||||
helper: UpdateCenterHelperStatus | null;
|
||||
};
|
||||
|
||||
@@ -66,6 +67,13 @@ function normalizeVersionCandidate(input: unknown): UpdateCenterVersionCandidate
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVersionCandidates(input: unknown): UpdateCenterVersionCandidate[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input
|
||||
.map((entry) => normalizeVersionCandidate(entry))
|
||||
.filter((entry): entry is UpdateCenterVersionCandidate => !!entry);
|
||||
}
|
||||
|
||||
function normalizeHelperHistoryEntry(input: unknown): NonNullable<UpdateCenterHelperStatus['history']>[number] | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const record = input as Record<string, unknown>;
|
||||
@@ -109,6 +117,7 @@ function normalizeStatusSnapshot(input: unknown): UpdateCenterStatusSnapshot | n
|
||||
return {
|
||||
githubRelease: normalizeVersionCandidate(record.githubRelease),
|
||||
dockerHubTag: normalizeVersionCandidate(record.dockerHubTag),
|
||||
dockerHubRecentTags: normalizeVersionCandidates(record.dockerHubRecentTags),
|
||||
helper: normalizeHelperSnapshot(record.helper),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { config as runtimeConfig } from '../config.js';
|
||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||
import { listBackgroundTasks } from './backgroundTaskService.js';
|
||||
import {
|
||||
fetchLatestDockerHubTag,
|
||||
fetchDockerHubTagCandidates,
|
||||
fetchLatestStableGitHubRelease,
|
||||
getCurrentRuntimeVersion,
|
||||
type DockerHubTagCandidates,
|
||||
type UpdateCenterVersionCandidate,
|
||||
} from './updateCenterVersionService.js';
|
||||
import {
|
||||
@@ -71,6 +72,7 @@ export type UpdateCenterStatusResult = {
|
||||
config: UpdateCenterConfig;
|
||||
githubRelease: UpdateCenterVersionCandidate | null;
|
||||
dockerHubTag: UpdateCenterVersionCandidate | null;
|
||||
dockerHubRecentTags: UpdateCenterVersionCandidate[];
|
||||
helper: UpdateCenterHelperStatus;
|
||||
runningTask: ReturnType<typeof getDeployTasks>[number] | null;
|
||||
lastFinishedTask: ReturnType<typeof getDeployTasks>[number] | null;
|
||||
@@ -92,16 +94,17 @@ function buildUnavailableHelperStatus(error: string | null = null): UpdateCenter
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatusSnapshot(status: Pick<UpdateCenterStatusResult, 'githubRelease' | 'dockerHubTag' | 'helper'>): UpdateCenterStatusSnapshot {
|
||||
function buildStatusSnapshot(status: Pick<UpdateCenterStatusResult, 'githubRelease' | 'dockerHubTag' | 'dockerHubRecentTags' | 'helper'>): UpdateCenterStatusSnapshot {
|
||||
return {
|
||||
githubRelease: status.githubRelease || null,
|
||||
dockerHubTag: status.dockerHubTag || null,
|
||||
dockerHubRecentTags: status.dockerHubRecentTags || [],
|
||||
helper: status.helper || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNextRuntimeState(
|
||||
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'helper'>,
|
||||
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'dockerHubRecentTags' | 'helper'>,
|
||||
previousRuntime: UpdateCenterRuntimeState,
|
||||
checkedAt: string,
|
||||
): { candidate: UpdateReminderCandidate | null; nextRuntime: UpdateCenterRuntimeState } {
|
||||
@@ -137,6 +140,7 @@ function buildResponseFromState(config: UpdateCenterConfig, runtime: UpdateCente
|
||||
config,
|
||||
githubRelease: snapshot?.githubRelease || null,
|
||||
dockerHubTag: snapshot?.dockerHubTag || null,
|
||||
dockerHubRecentTags: snapshot?.dockerHubRecentTags || [],
|
||||
helper: snapshot?.helper || buildUnavailableHelperStatus(runtime.lastCheckError),
|
||||
runningTask,
|
||||
lastFinishedTask,
|
||||
@@ -150,7 +154,7 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
||||
|
||||
const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([
|
||||
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
|
||||
settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()),
|
||||
settleOptional(config.dockerHubTagsEnabled, async () => await fetchDockerHubTagCandidates()),
|
||||
settleOptional(!!config.helperBaseUrl, async () => {
|
||||
if (!helperToken) {
|
||||
throw new Error('DEPLOY_HELPER_TOKEN is required');
|
||||
@@ -161,7 +165,9 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
||||
]);
|
||||
|
||||
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
|
||||
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null;
|
||||
const dockerHubCandidates = dockerLookup.value as DockerHubTagCandidates | null;
|
||||
const dockerHubTag = dockerHubCandidates?.primary || null;
|
||||
const dockerHubRecentTags = dockerHubCandidates?.recentNonStable || [];
|
||||
const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
|
||||
|
||||
const tasks = getDeployTasks();
|
||||
@@ -173,6 +179,7 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
||||
config,
|
||||
githubRelease,
|
||||
dockerHubTag,
|
||||
dockerHubRecentTags,
|
||||
helper,
|
||||
runningTask,
|
||||
lastFinishedTask,
|
||||
|
||||
@@ -14,12 +14,15 @@ vi.mock('undici', async () => {
|
||||
|
||||
import {
|
||||
compareStableSemVer,
|
||||
fetchDockerHubTagCandidates,
|
||||
fetchLatestDockerHubTag,
|
||||
fetchLatestStableGitHubRelease,
|
||||
parseStableSemVer,
|
||||
resolvePreferredDeploySource,
|
||||
selectDockerHubTagCandidates,
|
||||
selectLatestDockerHubTag,
|
||||
selectLatestStableGitHubRelease,
|
||||
selectRecentNonStableDockerHubTags,
|
||||
} from './updateCenterVersionService.js';
|
||||
|
||||
describe('update center version service', () => {
|
||||
@@ -132,6 +135,84 @@ describe('update center version service', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectRecentNonStableDockerHubTags', () => {
|
||||
it('returns recent non-stable docker tags with dev-like tags prioritized ahead of generic branch tags', () => {
|
||||
const candidates = selectRecentNonStableDockerHubTags([
|
||||
{
|
||||
name: 'feature-login',
|
||||
digest: 'sha256:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
tag_last_pushed: '2026-04-17T10:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'sha-a2c2ae6',
|
||||
digest: 'sha256:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
tag_last_pushed: '2026-04-17T09:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'dev',
|
||||
digest: 'sha256:3333333333333333333333333333333333333333333333333333333333333333',
|
||||
tag_last_pushed: '2026-04-17T08:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:4444444444444444444444444444444444444444444444444444444444444444',
|
||||
tag_last_pushed: '2026-04-17T07:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'latest',
|
||||
digest: 'sha256:5555555555555555555555555555555555555555555555555555555555555555',
|
||||
tag_last_pushed: '2026-04-17T11:00:00Z',
|
||||
},
|
||||
{
|
||||
name: '1.10.0',
|
||||
digest: 'sha256:6666666666666666666666666666666666666666666666666666666666666666',
|
||||
tag_last_pushed: '2026-04-16T11:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(candidates).toHaveLength(4);
|
||||
expect(candidates.map((candidate) => candidate.tagName)).toEqual([
|
||||
'dev',
|
||||
'dev-20260417-f67ade2',
|
||||
'sha-a2c2ae6',
|
||||
'feature-login',
|
||||
]);
|
||||
expect(candidates[0]).toMatchObject({
|
||||
displayVersion: 'dev @ sha256:333333333333',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectDockerHubTagCandidates', () => {
|
||||
it('returns both the stable primary candidate and recent non-stable candidates from one tag list', () => {
|
||||
const candidates = selectDockerHubTagCandidates([
|
||||
{
|
||||
name: 'latest',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
tag_last_pushed: '2026-04-17T12:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
tag_last_pushed: '2026-04-17T11:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'sha-f67ade2',
|
||||
digest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
tag_last_pushed: '2026-04-17T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(candidates.primary).toMatchObject({
|
||||
tagName: 'latest',
|
||||
});
|
||||
expect(candidates.recentNonStable.map((candidate) => candidate.tagName)).toEqual([
|
||||
'dev-20260417-f67ade2',
|
||||
'sha-f67ade2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePreferredDeploySource', () => {
|
||||
it('prefers the configured default source when both channels have valid candidates', () => {
|
||||
const preferred = resolvePreferredDeploySource({
|
||||
@@ -263,4 +344,40 @@ describe('update center version service', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDockerHubTagCandidates', () => {
|
||||
it('returns the stable docker candidate plus recent non-stable tags from one lookup', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
name: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
tag_last_pushed: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
name: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
tag_last_pushed: '2026-03-30T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
name: 'sha-f67ade2',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
tag_last_pushed: '2026-03-30T10:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const candidates = await fetchDockerHubTagCandidates();
|
||||
expect(candidates.primary).toMatchObject({
|
||||
tagName: 'latest',
|
||||
});
|
||||
expect(candidates.recentNonStable.map((candidate) => candidate.tagName)).toEqual([
|
||||
'dev',
|
||||
'sha-f67ade2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,11 +39,17 @@ export type DockerHubTagRecord = {
|
||||
digest?: string | null;
|
||||
};
|
||||
|
||||
export type DockerHubTagCandidates = {
|
||||
primary: UpdateCenterVersionCandidate | null;
|
||||
recentNonStable: UpdateCenterVersionCandidate[];
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100';
|
||||
const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||
const PREFERRED_DOCKER_HUB_TAG_ALIASES = ['latest', 'main'] as const;
|
||||
const MAX_RECENT_NON_STABLE_DOCKER_HUB_TAGS = 5;
|
||||
|
||||
async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
@@ -133,6 +139,21 @@ function normalizeDockerHubTagRecord(input: string | DockerHubTagRecord): Docker
|
||||
return input;
|
||||
}
|
||||
|
||||
function normalizeDockerHubTagName(input: string | null | undefined): string {
|
||||
return String(input || '').trim();
|
||||
}
|
||||
|
||||
function isPreferredDockerHubAlias(input: string | null | undefined): boolean {
|
||||
const tag = normalizeDockerHubTagName(input);
|
||||
return PREFERRED_DOCKER_HUB_TAG_ALIASES.includes(tag as typeof PREFERRED_DOCKER_HUB_TAG_ALIASES[number]);
|
||||
}
|
||||
|
||||
function isStableDockerHubTag(input: string | null | undefined): boolean {
|
||||
const tag = normalizeDockerHubTagName(input);
|
||||
if (!tag) return false;
|
||||
return isPreferredDockerHubAlias(tag) || !!parseStableSemVer(tag);
|
||||
}
|
||||
|
||||
function normalizeDockerDigest(input: string | null | undefined): string | null {
|
||||
const digest = String(input || '').trim();
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : null;
|
||||
@@ -143,6 +164,22 @@ function getDockerHubTagPublishedAt(record: DockerHubTagRecord): string | null {
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function getDockerHubTagPublishedTimestamp(record: DockerHubTagRecord): number {
|
||||
const publishedAt = getDockerHubTagPublishedAt(record);
|
||||
if (!publishedAt) return Number.NEGATIVE_INFINITY;
|
||||
const timestamp = Date.parse(publishedAt);
|
||||
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
function getRecentNonStableDockerHubPriority(input: string | null | undefined): number {
|
||||
const tag = normalizeDockerHubTagName(input).toLowerCase();
|
||||
if (!tag) return 99;
|
||||
if (tag === 'dev') return 0;
|
||||
if (tag.startsWith('dev-')) return 1;
|
||||
if (tag.startsWith('sha-')) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function toShortDigest(digest: string | null | undefined): string | null {
|
||||
if (!digest) return null;
|
||||
return digest.slice(0, 'sha256:'.length + 12);
|
||||
@@ -194,6 +231,44 @@ export function selectLatestDockerHubTag(tags: Array<string | DockerHubTagRecord
|
||||
return buildDockerHubVersionCandidate(selected.record, selected.semver.normalized);
|
||||
}
|
||||
|
||||
export function selectRecentNonStableDockerHubTags(
|
||||
tags: Array<string | DockerHubTagRecord>,
|
||||
limit = MAX_RECENT_NON_STABLE_DOCKER_HUB_TAGS,
|
||||
): UpdateCenterVersionCandidate[] {
|
||||
const records = tags
|
||||
.map((tag) => normalizeDockerHubTagRecord(tag))
|
||||
.filter((record) => normalizeDockerHubTagName(record.name))
|
||||
.filter((record) => !isStableDockerHubTag(record.name));
|
||||
|
||||
const deduped = new Map<string, DockerHubTagRecord>();
|
||||
for (const record of records) {
|
||||
const tagName = normalizeDockerHubTagName(record.name);
|
||||
const previous = deduped.get(tagName);
|
||||
if (!previous || getDockerHubTagPublishedTimestamp(record) > getDockerHubTagPublishedTimestamp(previous)) {
|
||||
deduped.set(tagName, record);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(deduped.values())
|
||||
.sort((a, b) => {
|
||||
const priorityDelta = getRecentNonStableDockerHubPriority(a.name) - getRecentNonStableDockerHubPriority(b.name);
|
||||
if (priorityDelta !== 0) return priorityDelta;
|
||||
const publishedDelta = getDockerHubTagPublishedTimestamp(b) - getDockerHubTagPublishedTimestamp(a);
|
||||
if (publishedDelta !== 0) return publishedDelta;
|
||||
return normalizeDockerHubTagName(a.name).localeCompare(normalizeDockerHubTagName(b.name));
|
||||
})
|
||||
.slice(0, Math.max(0, limit))
|
||||
.map((record) => buildDockerHubVersionCandidate(record, normalizeDockerHubTagName(record.name)))
|
||||
.filter((candidate): candidate is UpdateCenterVersionCandidate => !!candidate);
|
||||
}
|
||||
|
||||
export function selectDockerHubTagCandidates(tags: Array<string | DockerHubTagRecord>): DockerHubTagCandidates {
|
||||
return {
|
||||
primary: selectLatestDockerHubTag(tags),
|
||||
recentNonStable: selectRecentNonStableDockerHubTags(tags),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePreferredDeploySource(input: {
|
||||
defaultSource: UpdateCenterVersionSource;
|
||||
githubRelease: UpdateCenterVersionCandidate | null;
|
||||
@@ -216,13 +291,17 @@ export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVers
|
||||
}
|
||||
|
||||
export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCandidate | null> {
|
||||
return (await fetchDockerHubTagCandidates()).primary;
|
||||
}
|
||||
|
||||
export async function fetchDockerHubTagCandidates(): Promise<DockerHubTagCandidates> {
|
||||
const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
},
|
||||
}, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] };
|
||||
return selectLatestDockerHubTag(Array.isArray(payload?.results) ? payload.results : []);
|
||||
return selectDockerHubTagCandidates(Array.isArray(payload?.results) ? payload.results : []);
|
||||
}
|
||||
|
||||
export function getCurrentRuntimeVersion(): string {
|
||||
|
||||
Reference in New Issue
Block a user