feat(pages): 支持 GitHub Release 部署源

增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
deqiying
2026-07-19 18:31:42 +08:00
parent 38b0516937
commit c39a3edcc3
34 changed files with 5751 additions and 278 deletions
@@ -91,6 +91,74 @@ describe('PagesService', () => {
);
});
it('sends the complete GitHub latest discriminator payload', async () => {
vi.mocked(apiClient.post).mockResolvedValue(
response({
source: { source_type: 'manual' },
check_task: null,
warning: '',
}),
);
await PagesService.updateSource(12, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source/update',
{
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
},
undefined,
);
});
it('sends safe disabled defaults with the GitHub tag discriminator', async () => {
vi.mocked(apiClient.post).mockResolvedValue(
response({
source: { source_type: 'manual' },
check_task: null,
warning: '',
}),
);
await PagesService.updateSource(12, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'v1.2.3',
asset_name: 'site.tar.gz',
auto_update_enabled: false,
check_interval_minutes: 0,
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source/update',
{
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'v1.2.3',
asset_name: 'site.tar.gz',
auto_update_enabled: false,
check_interval_minutes: 0,
},
undefined,
);
});
it('uploads only the package multipart field', async () => {
vi.mocked(apiClient.post).mockResolvedValue(response({}));
const file = new File(['site'], 'site.zip', {
+446 -21
View File
@@ -1,13 +1,19 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
import { AdminTaskService } from '@/lib/services/admin';
import {
AdminTaskService,
type TaskExecution,
type TaskExecutionStatus,
} from '@/lib/services/admin';
import {
type PagesDeployment,
type PagesGitHubReleaseSource,
type PagesRemoteURLSource,
PagesService,
} from '@/lib/services/openflare';
@@ -21,6 +27,7 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
getSource: vi.fn(),
updateSource: vi.fn(),
deleteSource: vi.fn(),
checkSource: vi.fn(),
syncSource: vi.fn(),
listDeployments: vi.fn(),
listDeploymentFiles: vi.fn(),
@@ -67,11 +74,73 @@ const remoteSource: PagesRemoteURLSource = {
last_error: '',
};
const githubLatestSource: PagesGitHubReleaseSource = {
source_type: 'github_release',
github_repository: 'openflare/site',
release_selector: 'latest',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
sync_status: 'update_available',
update_available: true,
last_seen: {
revision: 'b'.repeat(64),
label: 'v1.2.3',
asset_name: 'dist.zip',
},
last_applied: {
revision: 'a'.repeat(64),
label: 'v1.2.2',
asset_name: 'dist.zip',
},
last_checked_at: '2026-07-19T10:00:00Z',
last_synced_at: '2026-07-18T10:00:00Z',
next_check_at: '2026-07-19T11:00:00Z',
last_error: '',
};
const githubAttentionRevision = 'c'.repeat(64);
const githubAttentionSource: PagesGitHubReleaseSource = {
...githubLatestSource,
sync_status: 'attention',
update_available: true,
last_seen: {
revision: githubAttentionRevision,
label: 'v1.2.3',
asset_name: 'dist.zip',
},
};
function taskExecution(
status: TaskExecutionStatus,
errorMessage = '',
): TaskExecution {
return {
id: '42',
task_id: 'manual_of_pages_source_action_1',
task_type: 'of_pages_source_action',
task_name: 'Pages 来源动作',
status,
retryable: false,
max_retry: 0,
retry_count: 0,
log: '',
error_message: errorMessage,
result: '',
duration: 1,
payload: '',
triggered_by: 'admin:1',
created_at: '2026-07-19T10:00:00Z',
updated_at: '2026-07-19T10:00:01Z',
};
}
describe('Pages source UI', () => {
beforeEach(() => {
vi.mocked(PagesService.getSource).mockReset();
vi.mocked(PagesService.updateSource).mockReset();
vi.mocked(PagesService.deleteSource).mockReset();
vi.mocked(PagesService.checkSource).mockReset();
vi.mocked(PagesService.syncSource).mockReset();
vi.mocked(PagesService.listDeployments).mockReset();
vi.mocked(PagesService.listDeploymentFiles).mockReset();
@@ -81,7 +150,8 @@ describe('Pages source UI', () => {
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
});
it('keeps Phase 1 manual source focused on upload and Remote URL', async () => {
it('offers the three Phase 2 source types without future repository build controls', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
@@ -92,8 +162,327 @@ describe('Pages source UI', () => {
expect(
screen.getByRole('button', { name: '配置 Remote URL' }),
).toBeVisible();
expect(
screen.getByRole('button', { name: '配置 GitHub Release' }),
).toBeVisible();
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: '配置 GitHub Release' }),
);
expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible();
expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible();
expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible();
expect(
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
).toBeVisible();
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
});
it('submits the GitHub latest payload while keeping automatic controls hidden', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: githubLatestSource,
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
'dist.zip',
);
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
});
});
});
it('rejects non-canonical GitHub repository URL paths', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
const repositoryInput = screen.getByLabelText('GitHub 仓库 URL');
await user.type(repositoryInput, 'https://github.com//openflare/site');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(
screen.getByText(
'请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址',
),
).toBeVisible();
await user.clear(repositoryInput);
await user.type(repositoryInput, 'https://github.com/openflare/site/');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('submits the GitHub tag discriminator with safe disabled defaults', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: {
...githubLatestSource,
release_selector: 'tag',
release_tag: 'v1.2.3',
check_interval_minutes: undefined,
next_check_at: null,
},
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
await user.type(
screen.getByLabelText('Release tag'),
'release/candidate#1&channel=stable',
);
await user.clear(screen.getByLabelText('Release Asset 文件名'));
await user.type(
screen.getByLabelText('Release Asset 文件名'),
' site?arch=amd64#stable.zip ',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'release/candidate#1&channel=stable',
asset_name: ' site?arch=amd64#stable.zip ',
auto_update_enabled: false,
check_interval_minutes: 0,
});
});
});
it('rejects unsafe GitHub tag and asset values before saving', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
await user.type(screen.getByLabelText('Release tag'), 'release//candidate');
await user.clear(screen.getByLabelText('Release Asset 文件名'));
await user.type(
screen.getByLabelText('Release Asset 文件名'),
'../dist.zip',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(
screen.getByText(
'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)',
),
).toBeVisible();
expect(
screen.getByText(
'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符',
),
).toBeVisible();
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('polls the initial check receipt and represents the queued window locally', async () => {
const user = userEvent.setup();
const idleGitHubSource: PagesGitHubReleaseSource = {
...githubLatestSource,
sync_status: 'idle',
update_available: false,
};
vi.mocked(PagesService.getSource)
.mockResolvedValueOnce({ source_type: 'manual' })
.mockResolvedValue(idleGitHubSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: idleGitHubSource,
check_task: {
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
},
warning: '',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('pending'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
expect(screen.getByText('检查中')).toBeVisible();
});
const checkButton = screen.getByRole('button', { name: /检查更新/ });
expect(checkButton).toBeDisabled();
expect(within(checkButton).getByRole('status')).toBeVisible();
expect(PagesService.checkSource).not.toHaveBeenCalled();
expect(PagesService.syncSource).not.toHaveBeenCalled();
});
it('shows GitHub release state and hides Phase 3 controls', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('openflare/site')).toBeVisible();
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
expect(screen.getByText('有可用更新')).toBeVisible();
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
});
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('succeeded'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
await waitFor(() => {
expect(PagesService.checkSource).toHaveBeenCalledWith(9);
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
expect(PagesService.syncSource).not.toHaveBeenCalled();
});
});
it('renders a GitHub check dispatch error', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockRejectedValue(
new Error('GitHub API 暂不可用'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible();
});
it('renders a TaskExecution polling error with an explicit retry', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
});
vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue(
new Error('任务状态暂时不可读取'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible();
expect(screen.getByRole('button', { name: '重试' })).toBeVisible();
});
it('requires the exact currently displayed revision for attention sync', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource);
vi.mocked(PagesService.syncSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'sync',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('succeeded'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
const alert = await screen.findByRole('alert');
expect(
within(alert).getByText('Release Asset 发生变化,需要显式确认'),
).toBeVisible();
expect(within(alert).getByText(githubAttentionRevision)).toBeVisible();
await user.click(screen.getByRole('button', { name: '同步并发布' }));
const dialog = screen.getByRole('alertdialog');
expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible();
await user.click(
within(dialog).getByRole('button', { name: '确认并发布' }),
);
await waitFor(() => {
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {
confirmed_revision: githubAttentionRevision,
});
});
});
it('never reuses the masked URL as an editable value', async () => {
@@ -119,7 +508,13 @@ describe('Pages source UI', () => {
'https://artifacts.example.com/site.zip?token=...',
);
expect(input).toHaveValue('');
expect(input).toHaveAttribute('type', 'password');
await user.type(input, 'https://new.example.com/site.zip?token=new');
expect(
screen.queryByText('https://new.example.com/site.zip?token=new'),
).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '显示 Remote URL' }));
expect(input).toHaveAttribute('type', 'url');
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
await waitFor(() => {
@@ -130,6 +525,17 @@ describe('Pages source UI', () => {
remote_network_policy: 'public',
});
});
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: '编辑来源' }));
await user.click(screen.getByRole('button', { name: '更换地址' }));
const reopenedInput = screen.getByPlaceholderText(
'https://artifacts.example.com/site.zip?token=...',
);
expect(reopenedInput).toHaveValue('');
expect(reopenedInput).toHaveAttribute('type', 'password');
});
it('requires a second confirmation for trusted internal networking', async () => {
@@ -169,24 +575,9 @@ describe('Pages source UI', () => {
execution_id: '42',
action: 'sync',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue({
id: '42',
task_id: 'manual_of_pages_source_action_1',
task_type: 'of_pages_source_action',
task_name: 'Pages 来源动作',
status: 'succeeded',
retryable: false,
max_retry: 0,
retry_count: 0,
log: '',
error_message: '',
result: '',
duration: 1,
payload: '',
triggered_by: 'admin:1',
created_at: '2026-07-19T10:00:00Z',
updated_at: '2026-07-19T10:00:01Z',
});
vi.mocked(AdminTaskService.getTaskExecution)
.mockResolvedValueOnce(taskExecution('pending'))
.mockResolvedValue(taskExecution('succeeded'));
renderWithQuery(<PagesSourceCard projectId={9} />);
@@ -196,6 +587,14 @@ describe('Pages source UI', () => {
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
});
expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled();
await waitFor(
() => {
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2);
},
{ timeout: 3_500 },
);
});
it('shows the actual project entry and no one-off URL upload tab', () => {
@@ -224,4 +623,30 @@ describe('Pages source UI', () => {
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
});
it('renders the immutable GitHub deployment provenance in Chinese', async () => {
const deployment: PagesDeployment = {
id: 51,
project_id: 9,
deployment_number: 7,
checksum: 'd'.repeat(64),
status: 'active',
file_count: 12,
total_size: 4_096,
created_by: 'user:1',
source_type: 'github_release',
source_label: 'v1.2.3',
trigger_type: 'manual_sync',
created_at: '2026-07-19T10:00:00Z',
activated_at: '2026-07-19T10:00:01Z',
};
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
renderWithQuery(
<DeploymentHistory projectId={9} activeDeploymentId={51} />,
);
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
expect(screen.getByText('当前生产部署')).toBeVisible();
});
});
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
validGitHubAssetName,
validGitHubReleaseTag,
validGitHubRepositoryURL,
} from '@/app/(main)/pages/detail/components/pages-source-validation';
describe('Pages GitHub source validation', () => {
it('accepts only canonical public GitHub repository URLs', () => {
expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true);
expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe(
true,
);
const invalid = [
'http://github.com/acme/site',
'https://github.com//acme/site',
'https://github.com/acme/site/',
'https://github.com/acme/site/extra',
'https://github.com/acme/./site',
'https://github.com/acme/../site',
'https://github.com/acme/%73ite',
'https://github.com/acme/site?token=secret',
'https://github.com:443/acme/site',
String.raw`https://github.com/acme\site`,
];
for (const value of invalid) {
expect(validGitHubRepositoryURL(value), value).toBe(false);
}
});
it('mirrors Git ref rules while preserving legal release tag characters', () => {
const valid = [
'@',
'release/v1#stable&channel=prod',
'foo.LOCK',
'中文/发布=稳定',
];
for (const value of valid) {
expect(validGitHubReleaseTag(value), value).toBe(true);
}
const invalid = [
'',
'release v1',
'release~v1',
'release^v1',
'release:v1',
'release?v1',
'release*v1',
'release[v1',
String.raw`release\v1`,
'release..v1',
'release@{v1',
'release//v1',
'/release',
'release/',
'release.',
'.release',
'release/.candidate',
'release/v1.lock',
'release\nsecret',
'release\u2028secret',
'release\u202esecret',
'a'.repeat(256),
];
for (const value of invalid) {
expect(validGitHubReleaseTag(value), value).toBe(false);
}
});
it('preserves exact legal asset names and rejects path or display controls', () => {
const valid = [
'dist.zip',
'dist?channel=stable&part#1.zip',
' dist.zip ',
'build=production.zip',
];
for (const value of valid) {
expect(validGitHubAssetName(value), value).toBe(true);
}
const invalid = [
'',
'.',
'..',
'../dist.zip',
String.raw`dir\dist.zip`,
'dist\n.zip',
'dist\u2028.zip',
'dist\u202e.zip',
'a'.repeat(256),
'\ud800',
];
for (const value of invalid) {
expect(validGitHubAssetName(value), value).toBe(false);
}
});
});