Publish the dev-tag discovery upgrade to main on a clean release branch
This release branch snapshots the latest dev work onto current main so the update center can surface recent non-stable Docker tags without operators looking them up manually. The branch keeps stable Docker discovery as the primary path while carrying the new recent-tag auto-discovery, UI affordances, and regression coverage through the repo's release flow. Constraint: Publication must start from latest origin/main and merge origin/dev on a clean release branch Constraint: Stable Docker Hub discovery semantics must remain intact while adding recent non-stable discovery Rejected: Re-open or reuse the already-merged PR #494 branch | it has already been absorbed by main and deleted remotely Rejected: Publish directly from dev | repo workflow requires a clean release branch to main Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep future dev-tag publication work on fresh release branches cut from origin/main; do not revive merged release branches Tested: Focused update-center vitest suite, npm run typecheck, npm run repo:drift-check, npm run build:web, npm run docs:build, git diff --check (release branch) Not-tested: Full npm test remains red because tmp/sub2api frontend suites are still broken in this repo snapshot
This commit is contained in:
@@ -310,8 +310,9 @@ helper 端使用:
|
|||||||
|
|
||||||
补充一点:
|
补充一点:
|
||||||
|
|
||||||
- `Docker Hub` 里的**自动发现**仍然优先 `latest` / `main` / 稳定 SemVer 标签
|
- `Docker Hub` 里的**主候选**仍然优先 `latest` / `main` / 稳定 SemVer 标签
|
||||||
- 如果你要部署 `dev`、分支名、临时标签或 `sha-*` 这类非稳定标签,直接在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写即可
|
- 页面还会自动列出最近推送的 `dev`、分支名、临时标签或 `sha-*` 这类**非稳定标签**,并一起带出 digest
|
||||||
|
- 如果你要部署一个更老或更特殊、没出现在自动列表里的标签,仍然可以在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写
|
||||||
|
|
||||||
对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页:
|
对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页:
|
||||||
|
|
||||||
@@ -330,7 +331,7 @@ helper 端使用:
|
|||||||
- 版本来源发现了可部署版本
|
- 版本来源发现了可部署版本
|
||||||
- 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest
|
- 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest
|
||||||
- Deploy Helper 显示健康
|
- Deploy Helper 显示健康
|
||||||
4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到 `dev` / 分支 / 临时标签,就在 Docker Hub 卡片底部手动填写 tag,必要时连 digest 一起填
|
4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到最近的 `dev` / 分支 / 临时标签,可以直接点自动列出的那一项;只有更特殊的 tag 才需要手动填写,必要时连 digest 一起填
|
||||||
5. 在页面下方看部署日志
|
5. 在页面下方看部署日志
|
||||||
6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像
|
6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { waitForBackgroundTaskToReachTerminalState } from '../../test-fixtures/b
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
fetchLatestStableGitHubReleaseMock,
|
fetchLatestStableGitHubReleaseMock,
|
||||||
fetchLatestDockerHubTagMock,
|
fetchDockerHubTagCandidatesMock,
|
||||||
getUpdateCenterHelperStatusMock,
|
getUpdateCenterHelperStatusMock,
|
||||||
streamUpdateCenterDeployMock,
|
streamUpdateCenterDeployMock,
|
||||||
streamUpdateCenterRollbackMock,
|
streamUpdateCenterRollbackMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
||||||
fetchLatestDockerHubTagMock: vi.fn(),
|
fetchDockerHubTagCandidatesMock: vi.fn(),
|
||||||
getUpdateCenterHelperStatusMock: vi.fn(),
|
getUpdateCenterHelperStatusMock: vi.fn(),
|
||||||
streamUpdateCenterDeployMock: vi.fn(),
|
streamUpdateCenterDeployMock: vi.fn(),
|
||||||
streamUpdateCenterRollbackMock: vi.fn(),
|
streamUpdateCenterRollbackMock: vi.fn(),
|
||||||
@@ -26,7 +26,7 @@ vi.mock('../../services/updateCenterVersionService.js', async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args),
|
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 () => {
|
beforeEach(async () => {
|
||||||
fetchLatestStableGitHubReleaseMock.mockReset();
|
fetchLatestStableGitHubReleaseMock.mockReset();
|
||||||
fetchLatestDockerHubTagMock.mockReset();
|
fetchDockerHubTagCandidatesMock.mockReset();
|
||||||
getUpdateCenterHelperStatusMock.mockReset();
|
getUpdateCenterHelperStatusMock.mockReset();
|
||||||
streamUpdateCenterDeployMock.mockReset();
|
streamUpdateCenterDeployMock.mockReset();
|
||||||
streamUpdateCenterRollbackMock.mockReset();
|
streamUpdateCenterRollbackMock.mockReset();
|
||||||
@@ -139,6 +139,18 @@ describe('update center routes', () => {
|
|||||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||||
url: null,
|
url: null,
|
||||||
} as const;
|
} 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 = {
|
const helperStatus = {
|
||||||
ok: true,
|
ok: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
@@ -161,7 +173,10 @@ describe('update center routes', () => {
|
|||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
fetchLatestStableGitHubReleaseMock.mockResolvedValue(githubRelease);
|
fetchLatestStableGitHubReleaseMock.mockResolvedValue(githubRelease);
|
||||||
fetchLatestDockerHubTagMock.mockResolvedValue(dockerHubTag);
|
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||||
|
primary: dockerHubTag,
|
||||||
|
recentNonStable: dockerHubRecentTags,
|
||||||
|
});
|
||||||
getUpdateCenterHelperStatusMock.mockResolvedValue(helperStatus);
|
getUpdateCenterHelperStatusMock.mockResolvedValue(helperStatus);
|
||||||
|
|
||||||
const saveResponse = await app.inject({
|
const saveResponse = await app.inject({
|
||||||
@@ -216,6 +231,13 @@ describe('update center routes', () => {
|
|||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
},
|
},
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev',
|
||||||
|
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||||
|
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
},
|
||||||
|
],
|
||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
healthy: true,
|
healthy: true,
|
||||||
@@ -242,11 +264,14 @@ describe('update center routes', () => {
|
|||||||
|
|
||||||
it('returns partial status when a single version source lookup fails', async () => {
|
it('returns partial status when a single version source lookup fails', async () => {
|
||||||
fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
|
fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
|
||||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||||
source: 'docker-hub-tag',
|
primary: {
|
||||||
rawVersion: '1.3.1',
|
source: 'docker-hub-tag',
|
||||||
normalizedVersion: '1.3.1',
|
rawVersion: '1.3.1',
|
||||||
url: null,
|
normalizedVersion: '1.3.1',
|
||||||
|
url: null,
|
||||||
|
},
|
||||||
|
recentNonStable: [],
|
||||||
});
|
});
|
||||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -426,6 +451,18 @@ describe('update center routes', () => {
|
|||||||
publishedAt: '2026-03-31T09:00:00Z',
|
publishedAt: '2026-03-31T09:00:00Z',
|
||||||
url: null,
|
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: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
@@ -454,6 +491,12 @@ describe('update center routes', () => {
|
|||||||
dockerHubTag: {
|
dockerHubTag: {
|
||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
},
|
},
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev',
|
||||||
|
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||||
|
},
|
||||||
|
],
|
||||||
helper: {
|
helper: {
|
||||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
},
|
},
|
||||||
@@ -462,7 +505,7 @@ describe('update center routes', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled();
|
expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled();
|
||||||
expect(fetchLatestDockerHubTagMock).not.toHaveBeenCalled();
|
expect(fetchDockerHubTagCandidatesMock).not.toHaveBeenCalled();
|
||||||
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
|
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -477,15 +520,29 @@ describe('update center routes', () => {
|
|||||||
publishedAt: '2026-03-31T10:00:00Z',
|
publishedAt: '2026-03-31T10:00:00Z',
|
||||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1',
|
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1',
|
||||||
});
|
});
|
||||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||||
source: 'docker-hub-tag',
|
primary: {
|
||||||
rawVersion: 'latest',
|
source: 'docker-hub-tag',
|
||||||
normalizedVersion: 'latest',
|
rawVersion: 'latest',
|
||||||
tagName: 'latest',
|
normalizedVersion: 'latest',
|
||||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
tagName: 'latest',
|
||||||
displayVersion: 'latest @ sha256:dddddddddddd',
|
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||||
publishedAt: '2026-03-31T10:00:00Z',
|
displayVersion: 'latest @ sha256:dddddddddddd',
|
||||||
url: null,
|
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({
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -512,6 +569,12 @@ describe('update center routes', () => {
|
|||||||
dockerHubTag: {
|
dockerHubTag: {
|
||||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||||
},
|
},
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev-20260417-f67ade2',
|
||||||
|
digest: 'sha256:abababababababababababababababababababababababababababababababab',
|
||||||
|
},
|
||||||
|
],
|
||||||
helper: {
|
helper: {
|
||||||
revision: '13',
|
revision: '13',
|
||||||
},
|
},
|
||||||
@@ -520,7 +583,7 @@ describe('update center routes', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1);
|
expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchLatestDockerHubTagMock).toHaveBeenCalledTimes(1);
|
expect(fetchDockerHubTagCandidatesMock).toHaveBeenCalledTimes(1);
|
||||||
expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1);
|
expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1);
|
||||||
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
|
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
|
||||||
lastResolvedSource: 'github-release',
|
lastResolvedSource: 'github-release',
|
||||||
@@ -532,6 +595,11 @@ describe('update center routes', () => {
|
|||||||
dockerHubTag: expect.objectContaining({
|
dockerHubTag: expect.objectContaining({
|
||||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||||
}),
|
}),
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
expect.objectContaining({
|
||||||
|
normalizedVersion: 'dev-20260417-f67ade2',
|
||||||
|
}),
|
||||||
|
],
|
||||||
helper: expect.objectContaining({
|
helper: expect.objectContaining({
|
||||||
revision: '13',
|
revision: '13',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ describe('updateCenterPollingService', () => {
|
|||||||
publishedAt: '2026-03-31T12:00:00Z',
|
publishedAt: '2026-03-31T12:00:00Z',
|
||||||
},
|
},
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
|
dockerHubRecentTags: [],
|
||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
@@ -115,6 +116,7 @@ describe('updateCenterPollingService', () => {
|
|||||||
currentVersion: '1.2.3',
|
currentVersion: '1.2.3',
|
||||||
githubRelease: runtime.statusSnapshot.githubRelease,
|
githubRelease: runtime.statusSnapshot.githubRelease,
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
|
dockerHubRecentTags: [],
|
||||||
helper: runtime.statusSnapshot.helper,
|
helper: runtime.statusSnapshot.helper,
|
||||||
runtime,
|
runtime,
|
||||||
},
|
},
|
||||||
@@ -151,15 +153,16 @@ describe('updateCenterPollingService', () => {
|
|||||||
lastResolvedSource: 'github-release',
|
lastResolvedSource: 'github-release',
|
||||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||||
statusSnapshot: {
|
statusSnapshot: expect.objectContaining({
|
||||||
githubRelease: expect.objectContaining({
|
githubRelease: expect.objectContaining({
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
}),
|
}),
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
|
dockerHubRecentTags: [],
|
||||||
helper: expect.objectContaining({
|
helper: expect.objectContaining({
|
||||||
imageTag: '1.2.3',
|
imageTag: '1.2.3',
|
||||||
}),
|
}),
|
||||||
},
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
@@ -205,6 +208,7 @@ describe('updateCenterPollingService', () => {
|
|||||||
publishedAt: '2026-03-31T12:01:00Z',
|
publishedAt: '2026-03-31T12:01:00Z',
|
||||||
},
|
},
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
|
dockerHubRecentTags: [],
|
||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
@@ -223,6 +227,7 @@ describe('updateCenterPollingService', () => {
|
|||||||
currentVersion: '1.2.3',
|
currentVersion: '1.2.3',
|
||||||
githubRelease: runtime.statusSnapshot.githubRelease,
|
githubRelease: runtime.statusSnapshot.githubRelease,
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
|
dockerHubRecentTags: [],
|
||||||
helper: runtime.statusSnapshot.helper,
|
helper: runtime.statusSnapshot.helper,
|
||||||
runtime,
|
runtime,
|
||||||
},
|
},
|
||||||
@@ -251,15 +256,16 @@ describe('updateCenterPollingService', () => {
|
|||||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||||
lastNotifiedAt: expect.any(String),
|
lastNotifiedAt: expect.any(String),
|
||||||
statusSnapshot: {
|
statusSnapshot: expect.objectContaining({
|
||||||
githubRelease: expect.objectContaining({
|
githubRelease: expect.objectContaining({
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
}),
|
}),
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
|
dockerHubRecentTags: [],
|
||||||
helper: expect.objectContaining({
|
helper: expect.objectContaining({
|
||||||
imageTag: '1.2.3',
|
imageTag: '1.2.3',
|
||||||
}),
|
}),
|
||||||
},
|
}),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ describe('updateCenterRuntimeStateService', () => {
|
|||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
publishedAt: '2026-03-30T20:30:00Z',
|
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: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
@@ -142,6 +154,18 @@ describe('updateCenterRuntimeStateService', () => {
|
|||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
publishedAt: '2026-03-30T20:30:00Z',
|
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: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { UpdateCenterVersionCandidate, UpdateCenterVersionSource } from './
|
|||||||
export type UpdateCenterStatusSnapshot = {
|
export type UpdateCenterStatusSnapshot = {
|
||||||
githubRelease: UpdateCenterVersionCandidate | null;
|
githubRelease: UpdateCenterVersionCandidate | null;
|
||||||
dockerHubTag: UpdateCenterVersionCandidate | null;
|
dockerHubTag: UpdateCenterVersionCandidate | null;
|
||||||
|
dockerHubRecentTags: UpdateCenterVersionCandidate[];
|
||||||
helper: UpdateCenterHelperStatus | null;
|
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 {
|
function normalizeHelperHistoryEntry(input: unknown): NonNullable<UpdateCenterHelperStatus['history']>[number] | null {
|
||||||
if (!input || typeof input !== 'object') return null;
|
if (!input || typeof input !== 'object') return null;
|
||||||
const record = input as Record<string, unknown>;
|
const record = input as Record<string, unknown>;
|
||||||
@@ -109,6 +117,7 @@ function normalizeStatusSnapshot(input: unknown): UpdateCenterStatusSnapshot | n
|
|||||||
return {
|
return {
|
||||||
githubRelease: normalizeVersionCandidate(record.githubRelease),
|
githubRelease: normalizeVersionCandidate(record.githubRelease),
|
||||||
dockerHubTag: normalizeVersionCandidate(record.dockerHubTag),
|
dockerHubTag: normalizeVersionCandidate(record.dockerHubTag),
|
||||||
|
dockerHubRecentTags: normalizeVersionCandidates(record.dockerHubRecentTags),
|
||||||
helper: normalizeHelperSnapshot(record.helper),
|
helper: normalizeHelperSnapshot(record.helper),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { config as runtimeConfig } from '../config.js';
|
|||||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
import { listBackgroundTasks } from './backgroundTaskService.js';
|
import { listBackgroundTasks } from './backgroundTaskService.js';
|
||||||
import {
|
import {
|
||||||
fetchLatestDockerHubTag,
|
fetchDockerHubTagCandidates,
|
||||||
fetchLatestStableGitHubRelease,
|
fetchLatestStableGitHubRelease,
|
||||||
getCurrentRuntimeVersion,
|
getCurrentRuntimeVersion,
|
||||||
|
type DockerHubTagCandidates,
|
||||||
type UpdateCenterVersionCandidate,
|
type UpdateCenterVersionCandidate,
|
||||||
} from './updateCenterVersionService.js';
|
} from './updateCenterVersionService.js';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +72,7 @@ export type UpdateCenterStatusResult = {
|
|||||||
config: UpdateCenterConfig;
|
config: UpdateCenterConfig;
|
||||||
githubRelease: UpdateCenterVersionCandidate | null;
|
githubRelease: UpdateCenterVersionCandidate | null;
|
||||||
dockerHubTag: UpdateCenterVersionCandidate | null;
|
dockerHubTag: UpdateCenterVersionCandidate | null;
|
||||||
|
dockerHubRecentTags: UpdateCenterVersionCandidate[];
|
||||||
helper: UpdateCenterHelperStatus;
|
helper: UpdateCenterHelperStatus;
|
||||||
runningTask: ReturnType<typeof getDeployTasks>[number] | null;
|
runningTask: ReturnType<typeof getDeployTasks>[number] | null;
|
||||||
lastFinishedTask: 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 {
|
return {
|
||||||
githubRelease: status.githubRelease || null,
|
githubRelease: status.githubRelease || null,
|
||||||
dockerHubTag: status.dockerHubTag || null,
|
dockerHubTag: status.dockerHubTag || null,
|
||||||
|
dockerHubRecentTags: status.dockerHubRecentTags || [],
|
||||||
helper: status.helper || null,
|
helper: status.helper || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNextRuntimeState(
|
function buildNextRuntimeState(
|
||||||
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'helper'>,
|
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'dockerHubRecentTags' | 'helper'>,
|
||||||
previousRuntime: UpdateCenterRuntimeState,
|
previousRuntime: UpdateCenterRuntimeState,
|
||||||
checkedAt: string,
|
checkedAt: string,
|
||||||
): { candidate: UpdateReminderCandidate | null; nextRuntime: UpdateCenterRuntimeState } {
|
): { candidate: UpdateReminderCandidate | null; nextRuntime: UpdateCenterRuntimeState } {
|
||||||
@@ -137,6 +140,7 @@ function buildResponseFromState(config: UpdateCenterConfig, runtime: UpdateCente
|
|||||||
config,
|
config,
|
||||||
githubRelease: snapshot?.githubRelease || null,
|
githubRelease: snapshot?.githubRelease || null,
|
||||||
dockerHubTag: snapshot?.dockerHubTag || null,
|
dockerHubTag: snapshot?.dockerHubTag || null,
|
||||||
|
dockerHubRecentTags: snapshot?.dockerHubRecentTags || [],
|
||||||
helper: snapshot?.helper || buildUnavailableHelperStatus(runtime.lastCheckError),
|
helper: snapshot?.helper || buildUnavailableHelperStatus(runtime.lastCheckError),
|
||||||
runningTask,
|
runningTask,
|
||||||
lastFinishedTask,
|
lastFinishedTask,
|
||||||
@@ -150,7 +154,7 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
|||||||
|
|
||||||
const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([
|
const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([
|
||||||
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
|
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
|
||||||
settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()),
|
settleOptional(config.dockerHubTagsEnabled, async () => await fetchDockerHubTagCandidates()),
|
||||||
settleOptional(!!config.helperBaseUrl, async () => {
|
settleOptional(!!config.helperBaseUrl, async () => {
|
||||||
if (!helperToken) {
|
if (!helperToken) {
|
||||||
throw new Error('DEPLOY_HELPER_TOKEN is required');
|
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 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 helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
|
||||||
|
|
||||||
const tasks = getDeployTasks();
|
const tasks = getDeployTasks();
|
||||||
@@ -173,6 +179,7 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
|||||||
config,
|
config,
|
||||||
githubRelease,
|
githubRelease,
|
||||||
dockerHubTag,
|
dockerHubTag,
|
||||||
|
dockerHubRecentTags,
|
||||||
helper,
|
helper,
|
||||||
runningTask,
|
runningTask,
|
||||||
lastFinishedTask,
|
lastFinishedTask,
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ vi.mock('undici', async () => {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
compareStableSemVer,
|
compareStableSemVer,
|
||||||
|
fetchDockerHubTagCandidates,
|
||||||
fetchLatestDockerHubTag,
|
fetchLatestDockerHubTag,
|
||||||
fetchLatestStableGitHubRelease,
|
fetchLatestStableGitHubRelease,
|
||||||
parseStableSemVer,
|
parseStableSemVer,
|
||||||
resolvePreferredDeploySource,
|
resolvePreferredDeploySource,
|
||||||
|
selectDockerHubTagCandidates,
|
||||||
selectLatestDockerHubTag,
|
selectLatestDockerHubTag,
|
||||||
selectLatestStableGitHubRelease,
|
selectLatestStableGitHubRelease,
|
||||||
|
selectRecentNonStableDockerHubTags,
|
||||||
} from './updateCenterVersionService.js';
|
} from './updateCenterVersionService.js';
|
||||||
|
|
||||||
describe('update center version service', () => {
|
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', () => {
|
describe('resolvePreferredDeploySource', () => {
|
||||||
it('prefers the configured default source when both channels have valid candidates', () => {
|
it('prefers the configured default source when both channels have valid candidates', () => {
|
||||||
const preferred = resolvePreferredDeploySource({
|
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;
|
digest?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DockerHubTagCandidates = {
|
||||||
|
primary: UpdateCenterVersionCandidate | null;
|
||||||
|
recentNonStable: UpdateCenterVersionCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
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 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 UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||||
const PREFERRED_DOCKER_HUB_TAG_ALIASES = ['latest', 'main'] as const;
|
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> {
|
async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise<unknown> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -133,6 +139,21 @@ function normalizeDockerHubTagRecord(input: string | DockerHubTagRecord): Docker
|
|||||||
return input;
|
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 {
|
function normalizeDockerDigest(input: string | null | undefined): string | null {
|
||||||
const digest = String(input || '').trim();
|
const digest = String(input || '').trim();
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : null;
|
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;
|
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 {
|
function toShortDigest(digest: string | null | undefined): string | null {
|
||||||
if (!digest) return null;
|
if (!digest) return null;
|
||||||
return digest.slice(0, 'sha256:'.length + 12);
|
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);
|
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: {
|
export function resolvePreferredDeploySource(input: {
|
||||||
defaultSource: UpdateCenterVersionSource;
|
defaultSource: UpdateCenterVersionSource;
|
||||||
githubRelease: UpdateCenterVersionCandidate | null;
|
githubRelease: UpdateCenterVersionCandidate | null;
|
||||||
@@ -216,13 +291,17 @@ export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVers
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCandidate | null> {
|
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, {
|
const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
'user-agent': 'metapi-update-center/1.0',
|
'user-agent': 'metapi-update-center/1.0',
|
||||||
},
|
},
|
||||||
}, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] };
|
}, '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 {
|
export function getCurrentRuntimeVersion(): string {
|
||||||
|
|||||||
@@ -73,6 +73,22 @@ describe('UpdateCenterSection', () => {
|
|||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||||
},
|
},
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev',
|
||||||
|
tagName: 'dev',
|
||||||
|
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||||
|
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev-20260417-f67ade2',
|
||||||
|
tagName: 'dev-20260417-f67ade2',
|
||||||
|
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
displayVersion: 'dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb',
|
||||||
|
publishedAt: '2026-03-30T10:54:35.591877Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
healthy: true,
|
healthy: true,
|
||||||
@@ -159,6 +175,15 @@ describe('UpdateCenterSection', () => {
|
|||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||||
},
|
},
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev',
|
||||||
|
tagName: 'dev',
|
||||||
|
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||||
|
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
healthy: true,
|
healthy: true,
|
||||||
@@ -431,6 +456,50 @@ describe('UpdateCenterSection', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deploys auto-discovered recent non-stable Docker Hub tags without manual input', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<UpdateCenterSection />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const recentTagCard = root.root.find((node) => (
|
||||||
|
typeof node.props?.style === 'object'
|
||||||
|
&& collectText(node).includes('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb')
|
||||||
|
));
|
||||||
|
expect(recentTagCard).toBeTruthy();
|
||||||
|
|
||||||
|
const deployRecentButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('部署 dev-20260417-f67ade2')
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(deployRecentButton.props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await deployRecentButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||||
|
source: 'docker-hub-tag',
|
||||||
|
targetTag: 'dev-20260417-f67ade2',
|
||||||
|
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
});
|
||||||
|
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
||||||
let root!: ReactTestRenderer;
|
let root!: ReactTestRenderer;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ type UpdateCenterStatus = {
|
|||||||
digest?: string | null;
|
digest?: string | null;
|
||||||
publishedAt?: string | null;
|
publishedAt?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
dockerHubRecentTags?: Array<{
|
||||||
|
normalizedVersion?: string;
|
||||||
|
displayVersion?: string;
|
||||||
|
tagName?: string;
|
||||||
|
digest?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
}> | null;
|
||||||
helper?: {
|
helper?: {
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
healthy?: boolean;
|
healthy?: boolean;
|
||||||
@@ -214,6 +221,11 @@ function formatImageTarget(tag?: string | null, digest?: string | null) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRecentDockerCandidates(input?: UpdateCenterStatus['dockerHubRecentTags'] | null) {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
return input.filter((entry) => String(entry?.tagName || entry?.normalizedVersion || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
export default function UpdateCenterSection() {
|
export default function UpdateCenterSection() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -432,6 +444,11 @@ export default function UpdateCenterSection() {
|
|||||||
const canDeployDocker = !deploying && dockerDeployState.canDeploy;
|
const canDeployDocker = !deploying && dockerDeployState.canDeploy;
|
||||||
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
|
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
|
||||||
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
|
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
|
||||||
|
const recentDockerCandidates = normalizeRecentDockerCandidates(status?.dockerHubRecentTags);
|
||||||
|
const canDeployDockerByTag = !deploying
|
||||||
|
&& config.enabled
|
||||||
|
&& config.dockerHubTagsEnabled
|
||||||
|
&& helperHealthy;
|
||||||
const canDeployManualDocker = !deploying
|
const canDeployManualDocker = !deploying
|
||||||
&& config.enabled
|
&& config.enabled
|
||||||
&& config.dockerHubTagsEnabled
|
&& config.dockerHubTagsEnabled
|
||||||
@@ -570,7 +587,7 @@ export default function UpdateCenterSection() {
|
|||||||
/>
|
/>
|
||||||
<span style={{ display: 'grid', gap: 4 }}>
|
<span style={{ display: 'grid', gap: 4 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>Docker Hub</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>Docker Hub</span>
|
||||||
<span style={fieldHintStyle}>自动发现优先稳定标签;dev / 分支 / 临时标签可在下方手动部署。</span>
|
<span style={fieldHintStyle}>自动发现会保留稳定主候选,并额外列出最近的 dev / 分支 / sha 标签。</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -731,7 +748,7 @@ export default function UpdateCenterSection() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>自动候选优先 latest / main / 稳定 SemVer;dev / 分支 / sha 标签可手动填写部署。</div>
|
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>主候选优先 latest / main / 稳定 SemVer;下方还会自动列出最近的 dev / 分支 / sha 标签。</div>
|
||||||
<div className={dockerDeployState.highlight ? 'stat-value-glow' : ''} style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
<div className={dockerDeployState.highlight ? 'stat-value-glow' : ''} style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||||
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
|
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||||
</div>
|
</div>
|
||||||
@@ -773,12 +790,82 @@ export default function UpdateCenterSection() {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ borderTop: '1px dashed var(--color-border-light)', marginTop: 4, paddingTop: 12, marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}>
|
||||||
|
最近非稳定 Docker 标签
|
||||||
|
</div>
|
||||||
|
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>
|
||||||
|
自动列出最近推送的 dev / 分支 / sha 标签;点部署即可直接使用,不必手动输入 tag 和 digest。
|
||||||
|
</div>
|
||||||
|
{recentDockerCandidates.length ? (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{recentDockerCandidates.map((candidate) => {
|
||||||
|
const candidateTag = String(candidate.tagName || candidate.normalizedVersion || '').trim();
|
||||||
|
const candidateDigest = String(candidate.digest || '').trim();
|
||||||
|
const candidateLabel = candidate.displayVersion || candidate.normalizedVersion || candidateTag;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${candidateTag}:${candidateDigest || 'no-digest'}`}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: 10,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 0 }}>
|
||||||
|
{candidateLabel}
|
||||||
|
</div>
|
||||||
|
<div style={fieldHintStyle}>
|
||||||
|
最近推送:{formatTaskTime(candidate.publishedAt)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
disabled={!canDeployDockerByTag || !candidateTag}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canDeployDockerByTag || !candidateTag) return;
|
||||||
|
void runDeploy('docker-hub-tag', {
|
||||||
|
tag: candidateTag,
|
||||||
|
digest: candidateDigest || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
部署 {candidateTag}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => {
|
||||||
|
setManualDockerTarget({
|
||||||
|
tag: candidateTag,
|
||||||
|
digest: candidateDigest,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
填入手动区
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={fieldHintStyle}>
|
||||||
|
暂未发现最近的 dev / 分支 / sha 标签;仍可在下方手动填写任意 Docker 标签。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ borderTop: '1px dashed var(--color-border-light)', marginTop: 4, paddingTop: 12 }}>
|
<div style={{ borderTop: '1px dashed var(--color-border-light)', marginTop: 4, paddingTop: 12 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}>
|
||||||
手动部署 Docker Hub 标签
|
手动部署 Docker Hub 标签
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>
|
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>
|
||||||
自动候选不会覆盖所有分支标签。若要切到 dev、feature、临时或 sha 标签,直接在这里填写。
|
自动发现已经覆盖最近的非稳定标签;如果你要部署更老或更特殊的 tag,仍可直接在这里填写。
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user