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
+1
View File
@@ -23,6 +23,7 @@ sidebar: false
### 新增
- Pages 项目新增持久部署源,可配置 Remote URL 或公开 GitHub Release,并支持手动检查、同步发布、来源状态查看与同一 Release 资源替换确认;部署历史会保留安全的来源快照。
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
### 改进
+2 -2
View File
@@ -8879,7 +8879,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
"description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
"produces": [
"application/json"
],
@@ -9112,7 +9112,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
"description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
"consumes": [
"application/json"
],
+8 -1
View File
@@ -211,9 +211,16 @@ of_pages_projects
GitHub:
```text
SHA-256("github|" + owner_repo + "|" + selector + "|" + tag + "|" + asset_name)
LP(value) = uint64be(byte_length(UTF8(value))) || UTF8(value)
SHA-256(
"openflare:pages:github-release:v2" ||
LP(owner_repo) || LP(selector) || LP(tag) || LP(asset_name)
)
```
GitHub identity 对每个 UTF-8 字段使用无歧义的长度前缀编码,不能使用分隔符直接拼接;自动更新开关和检查间隔不参与 identity。
Remote:
```text
+2 -2
View File
@@ -8872,7 +8872,7 @@
"SessionCookie": []
}
],
"description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
"produces": [
"application/json"
],
@@ -9105,7 +9105,7 @@
"SessionCookie": []
}
],
"description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
"consumes": [
"application/json"
],
+2 -2
View File
@@ -9468,7 +9468,7 @@ paths:
- openflare-pages
/api/v1/d/pages/{id}/source/check:
post:
description: Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
description: 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
parameters:
- description: 项目 ID
in: path
@@ -9611,7 +9611,7 @@ paths:
post:
consumes:
- application/json
description: Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
description: 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
parameters:
- description: 项目 ID
in: path
@@ -45,7 +45,7 @@ const SOURCE_LABELS: Record<PagesDeployment['source_type'], string> = {
manual_upload: '本地上传',
manual_url: 'URL 导入',
remote_url: 'Remote URL',
github_release: 'GitHub Release',
github_release: 'GitHub',
};
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
@@ -172,7 +172,7 @@ export function DeploymentHistory({
) : deployments.length === 0 ? (
<EmptyStateWithBorder
title='暂无部署'
description='上传本地部署包,或配置 Remote URL 来源后同步发布。'
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
/>
) : (
deployments.map((deployment) => {
@@ -1,11 +1,28 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Download, Pencil, RefreshCw, RotateCcw } from 'lucide-react';
import {
Download,
Github,
Pencil,
RefreshCw,
RotateCcw,
Search,
} from 'lucide-react';
import { toast } from 'sonner';
import { ErrorInline } from '@/components/layout/error';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -21,12 +38,11 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { AdminTaskService } from '@/lib/services/admin';
import {
type PagesSource,
type PagesSourceActionPayload,
type PagesSourceActionReceipt,
type PagesSourceStatus,
PagesService,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import {
deploymentsQueryKey,
@@ -34,7 +50,11 @@ import {
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import { PagesSourceDialog } from './pages-source-dialog';
import { type PagesSourceMode, PagesSourceDialog } from './pages-source-dialog';
import {
GitHubSourceDetails,
RemoteSourceDetails,
} from './pages-source-details';
const ACTION_POLL_INTERVAL = 2_000;
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
@@ -59,10 +79,8 @@ interface ActiveAction {
startedAt: number;
}
function revisionSummary(source: PagesSource) {
if (source.source_type === 'manual' || !source.last_applied)
return '尚未应用';
return `${source.last_applied.label} · ${source.last_applied.revision.slice(0, 12)}`;
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
return action === 'check' ? '检查' : '同步并发布';
}
export function PagesSourceCard({ projectId }: { projectId: number }) {
@@ -70,11 +88,10 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
const handledExecutionID = useRef<string | null>(null);
const sourcePollingStartedAt = useRef<number | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'manual' | 'remote_url'>(
'manual',
);
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
const [actionTimedOut, setActionTimedOut] = useState(false);
const [attentionDialogOpen, setAttentionDialogOpen] = useState(false);
const sourceQuery = useQuery({
queryKey: sourceQueryKey(projectId),
@@ -102,8 +119,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
'task-execution',
activeAction?.receipt.execution_id ?? '',
],
queryFn: () =>
AdminTaskService.getTaskExecution(activeAction!.receipt.execution_id),
queryFn: () => {
const executionID = activeAction?.receipt.execution_id;
if (!executionID) throw new Error('缺少任务执行 ID');
return AdminTaskService.getTaskExecution(executionID);
},
enabled: Boolean(activeAction) && !actionTimedOut,
refetchInterval: (query) => {
if (actionTimedOut) return false;
@@ -114,6 +134,15 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
},
});
const beginActionPolling = useCallback(
(receipt: PagesSourceActionReceipt) => {
handledExecutionID.current = null;
setActiveAction({ receipt, startedAt: Date.now() });
setActionTimedOut(false);
},
[],
);
useEffect(() => {
if (!activeAction || actionTimedOut) return;
const elapsed = Date.now() - activeAction.startedAt;
@@ -124,7 +153,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
useEffect(() => {
const execution = executionQuery.data;
if (!execution || !['succeeded', 'failed'].includes(execution.status)) {
if (
!activeAction ||
!execution ||
!['succeeded', 'failed'].includes(execution.status)
) {
return;
}
if (handledExecutionID.current === execution.id) return;
@@ -142,21 +175,36 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
const actionLabel = sourceActionLabel(activeAction.receipt.action);
if (execution.status === 'succeeded') {
toast.success('部署源同步并发布完成');
toast.success(`部署源${actionLabel}完成`);
} else {
toast.error(execution.error_message || '部署源同步失败');
toast.error(execution.error_message || `部署源${actionLabel}失败`);
}
setActiveAction(null);
setActionTimedOut(false);
}, [executionQuery.data, projectId, queryClient]);
}, [activeAction, executionQuery.data, projectId, queryClient]);
const checkMutation = useMutation({
mutationFn: () => PagesService.checkSource(projectId),
onSuccess: async (receipt) => {
beginActionPolling(receipt);
await queryClient.invalidateQueries({
queryKey: sourceQueryKey(projectId),
});
toast.success('检查任务已提交');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '检查任务提交失败');
},
});
const syncMutation = useMutation({
mutationFn: () => PagesService.syncSource(projectId, {}),
mutationFn: (payload: PagesSourceActionPayload) =>
PagesService.syncSource(projectId, payload),
onSuccess: async (receipt) => {
handledExecutionID.current = null;
setActiveAction({ receipt, startedAt: Date.now() });
setActionTimedOut(false);
setAttentionDialogOpen(false);
beginActionPolling(receipt);
await queryClient.invalidateQueries({
queryKey: sourceQueryKey(projectId),
});
@@ -176,13 +224,38 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
const sourceBusy =
source?.source_type !== 'manual' &&
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
const actionsDisabled = syncMutation.isPending || executionBusy || sourceBusy;
const actionsDisabled =
checkMutation.isPending ||
syncMutation.isPending ||
executionBusy ||
sourceBusy;
const checkBusy =
checkMutation.isPending ||
(executionBusy && activeAction?.receipt.action === 'check') ||
(source?.source_type !== 'manual' && source?.sync_status === 'checking');
const syncBusy =
syncMutation.isPending ||
(executionBusy && activeAction?.receipt.action === 'sync') ||
(source?.source_type !== 'manual' && source?.sync_status === 'syncing');
const dispatchError = checkMutation.error ?? syncMutation.error;
const openSourceDialog = (mode: 'manual' | 'remote_url') => {
const openSourceDialog = (mode: PagesSourceMode) => {
setDialogMode(mode);
setDialogOpen(true);
};
const dispatchSync = () => {
checkMutation.reset();
if (
source?.source_type === 'github_release' &&
source.sync_status === 'attention'
) {
setAttentionDialogOpen(true);
return;
}
syncMutation.mutate({});
};
if (sourceQuery.isLoading) {
return (
<Card>
@@ -219,10 +292,22 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
);
}
const effectiveSourceStatus = executionBusy
? activeAction?.receipt.action === 'check'
? 'checking'
: 'syncing'
: source.source_type === 'manual'
? undefined
: (source.sync_status ?? 'idle');
const status =
source.source_type === 'manual'
? null
: SOURCE_STATUS[source.sync_status ?? 'idle'];
: SOURCE_STATUS[effectiveSourceStatus ?? 'idle'];
const attentionRevision =
source.source_type === 'github_release' &&
source.sync_status === 'attention'
? source.last_seen
: undefined;
return (
<>
@@ -250,47 +335,20 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
</p>
</div>
) : source.source_type === 'remote_url' ? (
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>脱敏地址</span>
<code className='truncate text-sm'>{source.display_url}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>网络策略</span>
<span className='text-sm font-medium'>
{source.remote_network_policy === 'trusted_internal'
? '受信内网模式'
: '公网安全模式'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近同步</span>
<span className='text-sm font-medium'>
{source.last_synced_at
? formatDateTime(source.last_synced_at)
: '尚未同步'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>
已应用 revision
</span>
<span className='font-mono text-sm'>
{revisionSummary(source)}
</span>
</div>
{source.last_error ? (
<div className='md:col-span-2'>
<ErrorInline message={source.last_error} />
</div>
) : null}
</div>
<RemoteSourceDetails source={source} />
) : (
<div className='rounded-lg border p-4 text-sm text-muted-foreground'>
当前版本暂不提供该来源类型的编辑界面。
</div>
<GitHubSourceDetails source={source} />
)}
{dispatchError ? (
<ErrorInline
message={
dispatchError instanceof Error
? dispatchError.message
: '来源任务提交失败'
}
/>
) : null}
{executionQuery.isError ? (
<ErrorInline
message={
@@ -327,30 +385,61 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
<CardFooter className='flex flex-wrap gap-2 border-t'>
{source.source_type === 'manual' ? (
<Button
type='button'
onClick={() => openSourceDialog('remote_url')}
>
<Download data-icon='inline-start' />
配置 Remote URL
</Button>
) : source.source_type === 'remote_url' ? (
<>
<Button
type='button'
variant='outline'
onClick={() => openSourceDialog('remote_url')}
>
<Download data-icon='inline-start' />
配置 Remote URL
</Button>
<Button
type='button'
onClick={() => openSourceDialog('github_release')}
>
<Github data-icon='inline-start' />
配置 GitHub Release
</Button>
</>
) : (
<>
<Button
type='button'
variant='outline'
disabled={actionsDisabled}
onClick={() => openSourceDialog('remote_url')}
onClick={() => openSourceDialog(source.source_type)}
>
<Pencil data-icon='inline-start' />
编辑来源
</Button>
{source.source_type === 'github_release' ? (
<Button
type='button'
variant='outline'
disabled={actionsDisabled}
onClick={() => {
syncMutation.reset();
checkMutation.mutate();
}}
>
{checkBusy ? (
<Spinner data-icon='inline-start' />
) : (
<Search data-icon='inline-start' />
)}
检查更新
</Button>
) : null}
<Button
type='button'
disabled={actionsDisabled}
onClick={() => syncMutation.mutate()}
disabled={
actionsDisabled ||
(source.sync_status === 'attention' && !attentionRevision)
}
onClick={dispatchSync}
>
{actionsDisabled ? (
{syncBusy ? (
<Spinner data-icon='inline-start' />
) : (
<Download data-icon='inline-start' />
@@ -367,7 +456,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
切换回手动
</Button>
</>
) : null}
)}
<Button
type='button'
variant='ghost'
@@ -387,7 +476,48 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
projectId={projectId}
source={source}
initialMode={dialogMode}
onActionDispatched={beginActionPolling}
/>
<AlertDialog
open={attentionDialogOpen}
onOpenChange={(open) => {
if (!syncMutation.isPending) setAttentionDialogOpen(open);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布当前 GitHub revision</AlertDialogTitle>
<AlertDialogDescription>
<span>这将发布卡片当前显示的精确 revision:</span>{' '}
<span className='break-all font-mono'>
{attentionRevision?.revision ??
'当前 revision 已不可用,请刷新后重试'}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={syncMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={syncMutation.isPending || !attentionRevision}
onClick={(event) => {
event.preventDefault();
if (!attentionRevision) return;
syncMutation.mutate({
confirmed_revision: attentionRevision.revision,
});
}}
>
{syncMutation.isPending ? (
<Spinner data-icon='inline-start' />
) : null}
确认并发布
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,137 @@
import { TriangleAlert } from 'lucide-react';
import { ErrorInline } from '@/components/layout/error';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
type PagesGitHubReleaseSource,
type PagesRemoteURLSource,
type PagesSourceRevision,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
function revisionSummary(revision?: PagesSourceRevision) {
if (!revision) return '尚无记录';
return `${revision.label} · ${revision.revision.slice(0, 12)}`;
}
export function RemoteSourceDetails({
source,
}: {
source: PagesRemoteURLSource;
}) {
return (
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>脱敏地址</span>
<code className='truncate text-sm'>{source.display_url}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>网络策略</span>
<span className='text-sm font-medium'>
{source.remote_network_policy === 'trusted_internal'
? '受信内网模式'
: '公网安全模式'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近同步</span>
<span className='text-sm font-medium'>
{source.last_synced_at
? formatDateTime(source.last_synced_at)
: '尚未同步'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>已应用 revision</span>
<span className='font-mono text-sm'>
{revisionSummary(source.last_applied)}
</span>
</div>
{source.last_error ? (
<div className='md:col-span-2'>
<ErrorInline message={source.last_error} />
</div>
) : null}
</div>
);
}
export function GitHubSourceDetails({
source,
}: {
source: PagesGitHubReleaseSource;
}) {
const attentionRevision =
source.sync_status === 'attention' ? source.last_seen : undefined;
return (
<div className='flex flex-col gap-4'>
{attentionRevision ? (
<Alert variant='destructive'>
<TriangleAlert />
<AlertTitle>Release Asset 发生变化,需要显式确认</AlertTitle>
<AlertDescription>
<p>
当前远端 revision
与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
revision。
</p>
<code className='break-all'>{attentionRevision.revision}</code>
</AlertDescription>
</Alert>
) : null}
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>GitHub 仓库</span>
<code className='truncate text-sm'>{source.github_repository}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>Release 选择</span>
<span className='text-sm font-medium'>
{source.release_selector === 'latest'
? '最新 Release'
: `固定 Tag · ${source.release_tag ?? '未提供'}`}
</span>
</div>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>Release Asset</span>
<code className='truncate text-sm'>{source.asset_name}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>远端已发现</span>
<span className='font-mono text-sm'>
{revisionSummary(source.last_seen)}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>当前已应用</span>
<span className='font-mono text-sm'>
{revisionSummary(source.last_applied)}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近检查</span>
<span className='text-sm font-medium'>
{source.last_checked_at
? formatDateTime(source.last_checked_at)
: '尚未检查'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近同步</span>
<span className='text-sm font-medium'>
{source.last_synced_at
? formatDateTime(source.last_synced_at)
: '尚未同步'}
</span>
</div>
{source.last_error ? (
<div className='md:col-span-2'>
<ErrorInline message={source.last_error} />
</div>
) : null}
</div>
</div>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -36,6 +37,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
type PagesRemoteNetworkPolicy,
type PagesSource,
type PagesSourceActionReceipt,
type PagesSourceUpdatePayload,
PagesService,
} from '@/lib/services/openflare';
@@ -45,8 +48,19 @@ import {
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import {
type PagesGitHubSourceFormErrors,
type PagesGitHubSourceFormValue,
PagesSourceGitHubFields,
} from './pages-source-github-fields';
import {
validGitHubAssetName,
validGitHubReleaseTag,
validGitHubRepositoryURL,
} from './pages-source-validation';
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
type SourceMode = 'manual' | 'remote_url';
type Confirmation = 'trusted_internal' | 'manual' | null;
interface PagesSourceDialogProps {
@@ -54,7 +68,21 @@ interface PagesSourceDialogProps {
onOpenChange: (open: boolean) => void;
projectId: number;
source: PagesSource;
initialMode?: SourceMode;
initialMode?: PagesSourceMode;
onActionDispatched?: (receipt: PagesSourceActionReceipt) => void;
}
const DEFAULT_GITHUB_ASSET = 'dist.zip';
const DEFAULT_GITHUB_CHECK_INTERVAL = 60;
const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
repository: '',
releaseTag: '',
assetName: '',
};
function githubRepositoryURL(repository: string) {
const value = repository.trim();
return value ? `https://github.com/${value}` : '';
}
export function PagesSourceDialog({
@@ -63,21 +91,33 @@ export function PagesSourceDialog({
projectId,
source,
initialMode,
onActionDispatched,
}: PagesSourceDialogProps) {
const queryClient = useQueryClient();
const [mode, setMode] = useState<SourceMode>('manual');
const [mode, setMode] = useState<PagesSourceMode>('manual');
const [networkPolicy, setNetworkPolicy] =
useState<PagesRemoteNetworkPolicy>('public');
const [replaceURL, setReplaceURL] = useState(false);
const [remoteURL, setRemoteURL] = useState('');
const [revealRemoteURL, setRevealRemoteURL] = useState(false);
const [urlError, setURLError] = useState('');
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
repositoryURL: '',
releaseSelector: 'latest',
releaseTag: '',
assetName: DEFAULT_GITHUB_ASSET,
});
const [githubErrors, setGitHubErrors] =
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
const [confirmation, setConfirmation] = useState<Confirmation>(null);
useEffect(() => {
if (!open) return;
const nextMode =
initialMode ??
(source.source_type === 'remote_url' ? 'remote_url' : 'manual');
if (!open) {
setRemoteURL('');
setRevealRemoteURL(false);
return;
}
const nextMode = initialMode ?? source.source_type;
setMode(nextMode);
setNetworkPolicy(
source.source_type === 'remote_url'
@@ -86,7 +126,27 @@ export function PagesSourceDialog({
);
setReplaceURL(source.source_type !== 'remote_url');
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
setGitHubForm({
repositoryURL:
source.source_type === 'github_release'
? githubRepositoryURL(source.github_repository)
: '',
releaseSelector:
source.source_type === 'github_release'
? source.release_selector
: 'latest',
releaseTag:
source.source_type === 'github_release'
? (source.release_tag ?? '')
: '',
assetName:
source.source_type === 'github_release'
? source.asset_name
: DEFAULT_GITHUB_ASSET,
});
setGitHubErrors(EMPTY_GITHUB_ERRORS);
setConfirmation(null);
}, [initialMode, open, source]);
@@ -102,19 +162,17 @@ export function PagesSourceDialog({
};
const updateMutation = useMutation({
mutationFn: () =>
PagesService.updateSource(projectId, {
source_type: 'remote_url',
remote_url_set: replaceURL,
remote_url: replaceURL ? remoteURL.trim() : '',
remote_network_policy: networkPolicy,
}),
mutationFn: (payload: PagesSourceUpdatePayload) =>
PagesService.updateSource(projectId, payload),
onSuccess: async (result) => {
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
if (result.check_task) onActionDispatched?.(result.check_task);
await invalidateSourceState();
toast.success('部署源已更新');
if (result.warning) toast.warning(result.warning);
setConfirmation(null);
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
},
onError: (error) => {
@@ -129,6 +187,8 @@ export function PagesSourceDialog({
await invalidateSourceState();
toast.success('已切换回手动部署');
setConfirmation(null);
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
},
onError: (error) => {
@@ -138,6 +198,13 @@ export function PagesSourceDialog({
const isPending = updateMutation.isPending || deleteMutation.isPending;
const remotePayload = (): PagesSourceUpdatePayload => ({
source_type: 'remote_url',
remote_url_set: replaceURL,
remote_url: replaceURL ? remoteURL.trim() : '',
remote_network_policy: networkPolicy,
});
const submitRemote = () => {
if (replaceURL) {
const value = remoteURL.trim();
@@ -158,21 +225,86 @@ export function PagesSourceDialog({
setConfirmation('trusted_internal');
return;
}
updateMutation.mutate();
updateMutation.mutate(remotePayload());
};
const submitGitHub = () => {
const normalizedRepositoryURL = githubForm.repositoryURL.trim();
const nextRepositoryError = validGitHubRepositoryURL(
normalizedRepositoryURL,
)
? ''
: '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址';
const nextReleaseTagError =
githubForm.releaseSelector === 'tag' &&
!validGitHubReleaseTag(githubForm.releaseTag)
? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)'
: '';
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
? ''
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
setGitHubErrors({
repository: nextRepositoryError,
releaseTag: nextReleaseTagError,
assetName: nextAssetNameError,
});
if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) {
return;
}
const payload: PagesSourceUpdatePayload =
githubForm.releaseSelector === 'latest'
? {
source_type: 'github_release',
repository_url: normalizedRepositoryURL,
release_selector: 'latest',
release_tag: '',
asset_name: githubForm.assetName,
auto_update_enabled: false,
check_interval_minutes:
source.source_type === 'github_release' &&
source.release_selector === 'latest' &&
source.check_interval_minutes
? source.check_interval_minutes
: DEFAULT_GITHUB_CHECK_INTERVAL,
}
: {
source_type: 'github_release',
repository_url: normalizedRepositoryURL,
release_selector: 'tag',
release_tag: githubForm.releaseTag,
asset_name: githubForm.assetName,
auto_update_enabled: false,
check_interval_minutes: 0,
};
updateMutation.mutate(payload);
};
const handleSubmit = () => {
if (mode === 'manual') {
if (source.source_type === 'manual') {
onOpenChange(false);
} else {
setConfirmation('manual');
}
return;
switch (mode) {
case 'manual':
if (source.source_type === 'manual') {
onOpenChange(false);
} else {
setConfirmation('manual');
}
return;
case 'remote_url':
submitRemote();
return;
case 'github_release':
submitGitHub();
}
submitRemote();
};
const submitLabel =
mode === 'manual'
? '使用手动部署'
: mode === 'remote_url'
? '保存 Remote 来源'
: '保存 GitHub 来源';
return (
<>
<Dialog
@@ -185,8 +317,7 @@ export function PagesSourceDialog({
<DialogHeader>
<DialogTitle>部署源设置</DialogTitle>
<DialogDescription>
手动部署与 Remote URL
各自保持独立配置;后续仓库构建来源会作为新的来源类型接入。
手动部署、Remote URL 与 GitHub Release 使用独立配置。
</DialogDescription>
</DialogHeader>
@@ -198,10 +329,19 @@ export function PagesSourceDialog({
variant='outline'
value={mode}
aria-labelledby='pages-source-mode'
className='grid w-full grid-cols-2'
className='grid w-full grid-cols-1 sm:grid-cols-3'
onValueChange={(value) => {
if (value === 'manual' || value === 'remote_url') {
if (
value === 'manual' ||
value === 'remote_url' ||
value === 'github_release'
) {
setMode(value);
if (value !== 'remote_url') {
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
}
}
}}
>
@@ -211,9 +351,17 @@ export function PagesSourceDialog({
<ToggleGroupItem value='remote_url' className='w-full'>
Remote URL
</ToggleGroupItem>
<ToggleGroupItem value='github_release' className='w-full'>
GitHub Release
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
手动部署由管理员上传本地包;Remote URL 通过显式同步下载并发布。
<span>
远端来源只负责发现内容,发布结果始终保留为不可变部署。
</span>
<span className='block'>
仓库源码构建将在后续作为独立来源类型提供。
</span>
</FieldDescription>
</Field>
@@ -224,7 +372,7 @@ export function PagesSourceDialog({
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
</div>
</Field>
) : (
) : mode === 'remote_url' ? (
<>
<Field data-invalid={Boolean(urlError)}>
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
@@ -240,24 +388,48 @@ export function PagesSourceDialog({
onClick={() => {
setReplaceURL(true);
setRemoteURL('');
setRevealRemoteURL(false);
}}
>
更换地址
</Button>
</div>
) : (
<Input
id='pages-remote-url'
type='url'
placeholder='https://artifacts.example.com/site.zip?token=...'
value={remoteURL}
aria-invalid={Boolean(urlError)}
autoComplete='off'
onChange={(event) => {
setRemoteURL(event.target.value);
setURLError('');
}}
/>
<div className='flex gap-2'>
<Input
id='pages-remote-url'
type={revealRemoteURL ? 'url' : 'password'}
placeholder='https://artifacts.example.com/site.zip?token=...'
value={remoteURL}
aria-invalid={Boolean(urlError)}
autoComplete='off'
className='min-w-0 flex-1'
onChange={(event) => {
setRemoteURL(event.target.value);
setURLError('');
}}
/>
<Button
type='button'
variant='outline'
size='icon'
aria-label={
revealRemoteURL
? '隐藏 Remote URL'
: '显示 Remote URL'
}
title={
revealRemoteURL
? '隐藏 Remote URL'
: '显示 Remote URL'
}
onClick={() =>
setRevealRemoteURL((visible) => !visible)
}
>
{revealRemoteURL ? <EyeOff /> : <Eye />}
</Button>
</div>
)}
<FieldDescription>
{urlError ||
@@ -274,6 +446,7 @@ export function PagesSourceDialog({
onClick={() => {
setReplaceURL(false);
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
}}
>
@@ -313,6 +486,14 @@ export function PagesSourceDialog({
</FieldDescription>
</Field>
</>
) : (
<PagesSourceGitHubFields
value={githubForm}
errors={githubErrors}
defaultAssetName={DEFAULT_GITHUB_ASSET}
onChange={setGitHubForm}
onErrorsChange={setGitHubErrors}
/>
)}
</FieldGroup>
@@ -321,13 +502,17 @@ export function PagesSourceDialog({
type='button'
variant='outline'
disabled={isPending}
onClick={() => onOpenChange(false)}
onClick={() => {
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
}}
>
取消
</Button>
<Button type='button' disabled={isPending} onClick={handleSubmit}>
{isPending ? <Spinner data-icon='inline-start' /> : null}
{mode === 'manual' ? '使用手动部署' : '保存 Remote 来源'}
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
@@ -361,7 +546,7 @@ export function PagesSourceDialog({
if (confirmation === 'manual') {
deleteMutation.mutate();
} else if (confirmation === 'trusted_internal') {
updateMutation.mutate();
updateMutation.mutate(remotePayload());
}
}}
>
@@ -0,0 +1,130 @@
import {
Field,
FieldDescription,
FieldLabel,
FieldTitle,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
export interface PagesGitHubSourceFormValue {
repositoryURL: string;
releaseSelector: PagesGitHubReleaseSelector;
releaseTag: string;
assetName: string;
}
export interface PagesGitHubSourceFormErrors {
repository: string;
releaseTag: string;
assetName: string;
}
interface PagesSourceGitHubFieldsProps {
value: PagesGitHubSourceFormValue;
errors: PagesGitHubSourceFormErrors;
defaultAssetName: string;
onChange: (value: PagesGitHubSourceFormValue) => void;
onErrorsChange: (errors: PagesGitHubSourceFormErrors) => void;
}
export function PagesSourceGitHubFields({
value,
errors,
defaultAssetName,
onChange,
onErrorsChange,
}: PagesSourceGitHubFieldsProps) {
return (
<>
<Field data-invalid={Boolean(errors.repository)}>
<FieldLabel htmlFor='pages-github-repository'>
GitHub 仓库 URL
</FieldLabel>
<Input
id='pages-github-repository'
type='url'
placeholder='https://github.com/owner/repo'
value={value.repositoryURL}
aria-invalid={Boolean(errors.repository)}
autoComplete='off'
onChange={(event) => {
onChange({ ...value, repositoryURL: event.target.value });
onErrorsChange({ ...errors, repository: '' });
}}
/>
<FieldDescription>
{errors.repository || '仅支持公开 github.com 仓库。'}
</FieldDescription>
</Field>
<Field>
<FieldTitle id='pages-github-selector'>Release 选择</FieldTitle>
<ToggleGroup
type='single'
variant='outline'
value={value.releaseSelector}
aria-labelledby='pages-github-selector'
className='grid w-full grid-cols-2'
onValueChange={(selector) => {
if (selector === 'latest' || selector === 'tag') {
onChange({ ...value, releaseSelector: selector });
onErrorsChange({ ...errors, releaseTag: '' });
}
}}
>
<ToggleGroupItem value='latest' className='w-full'>
最新 Release
</ToggleGroupItem>
<ToggleGroupItem value='tag' className='w-full'>
固定 Tag
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
当前阶段由管理员手动检查并决定是否发布。
</FieldDescription>
</Field>
{value.releaseSelector === 'tag' ? (
<Field data-invalid={Boolean(errors.releaseTag)}>
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
<Input
id='pages-github-tag'
placeholder='v1.2.3'
value={value.releaseTag}
aria-invalid={Boolean(errors.releaseTag)}
autoComplete='off'
onChange={(event) => {
onChange({ ...value, releaseTag: event.target.value });
onErrorsChange({ ...errors, releaseTag: '' });
}}
/>
<FieldDescription>
{errors.releaseTag || '精确检查并同步指定 tag。'}
</FieldDescription>
</Field>
) : null}
<Field data-invalid={Boolean(errors.assetName)}>
<FieldLabel htmlFor='pages-github-asset'>
Release Asset 文件名
</FieldLabel>
<Input
id='pages-github-asset'
placeholder={defaultAssetName}
value={value.assetName}
aria-invalid={Boolean(errors.assetName)}
autoComplete='off'
onChange={(event) => {
onChange({ ...value, assetName: event.target.value });
onErrorsChange({ ...errors, assetName: '' });
}}
/>
<FieldDescription>
{errors.assetName || '按文件名精确匹配已上传的 Release Asset。'}
</FieldDescription>
</Field>
</>
);
}
@@ -0,0 +1,68 @@
function hasWellFormedUnicode(value: string) {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
index += 1;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return false;
}
}
return true;
}
function validGitHubSafeText(value: string) {
return (
value !== '' &&
new TextEncoder().encode(value).byteLength <= 255 &&
!/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(
value,
) &&
hasWellFormedUnicode(value)
);
}
export function validGitHubRepositoryURL(raw: string) {
const match = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(raw);
if (!match) return false;
const [, host, owner, rawRepository] = match;
const repository = rawRepository.replace(/\.git$/u, '');
return (
host.toLowerCase() === 'github.com' &&
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/iu.test(owner) &&
/^[a-z0-9._-]+$/iu.test(repository) &&
repository.length <= 100 &&
!['.', '..'].includes(repository)
);
}
export function validGitHubAssetName(value: string) {
return (
validGitHubSafeText(value) &&
value !== '.' &&
value !== '..' &&
!value.includes('/') &&
!value.includes('\\')
);
}
export function validGitHubReleaseTag(value: string) {
const components = value.split('/');
return (
validGitHubSafeText(value) &&
!value.endsWith('.') &&
!value.includes('..') &&
!value.includes('@{') &&
![' ', '~', '^', ':', '?', '*', '[', '\\'].some((character) =>
value.includes(character),
) &&
components.every(
(component) =>
component !== '' &&
!component.startsWith('.') &&
!component.endsWith('.lock'),
)
);
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className,
)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
+5
View File
@@ -109,7 +109,11 @@ export type {
OriginMutationPayload,
PagesDeployment,
PagesDeploymentFile,
PagesGitHubLatestSourceUpdatePayload,
PagesGitHubReleaseSelector,
PagesGitHubReleaseSource,
PagesGitHubSourceUpdatePayload,
PagesGitHubTagSourceUpdatePayload,
PagesManualSource,
PagesProject,
PagesProjectPayload,
@@ -121,6 +125,7 @@ export type {
PagesSourceActionReceipt,
PagesSourceRevision,
PagesSourceStatus,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
AcmeAccountItem,
DnsAccountItem,
@@ -12,10 +12,10 @@ import type {
PagesDeploymentUploadPayload,
PagesProject,
PagesProjectPayload,
PagesRemoteSourceUpdatePayload,
PagesSource,
PagesSourceActionPayload,
PagesSourceActionReceipt,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
} from './types';
@@ -51,7 +51,7 @@ export class PagesService extends OpenFlareBaseService {
static updateSource(
projectId: number,
payload: PagesRemoteSourceUpdatePayload,
payload: PagesSourceUpdatePayload,
): Promise<PagesSourceUpdateResult> {
return this.post<PagesSourceUpdateResult>(
`/${projectId}/source/update`,
+36 -5
View File
@@ -462,6 +462,8 @@ export type PagesSourceStatus =
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
export interface PagesSourceRevision {
revision: string;
label: string;
@@ -493,15 +495,15 @@ export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
export interface PagesGitHubReleaseSource extends PagesSourceRuntimeView {
source_type: 'github_release';
github_repository: string;
release_selector: 'latest' | 'tag';
release_tag: string;
release_selector: PagesGitHubReleaseSelector;
release_tag?: string;
asset_name: string;
auto_update_enabled: boolean;
check_interval_minutes: number;
auto_update_enabled?: false;
check_interval_minutes?: number;
}
/**
* 部署源使用判别联合,后续仓库构建来源只需增加独立 variant,
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
* 不需要向 Remote 或 GitHub Release 填入构建字段。
*/
export type PagesSource =
@@ -514,6 +516,35 @@ export interface PagesRemoteSourceUpdatePayload {
remote_network_policy: PagesRemoteNetworkPolicy;
}
interface PagesGitHubSourceUpdateBase {
source_type: 'github_release';
repository_url: string;
asset_name: string;
auto_update_enabled: false;
}
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
release_selector: 'latest';
release_tag: '';
check_interval_minutes: number;
}
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
release_selector: 'tag';
release_tag: string;
check_interval_minutes: 0;
}
export type PagesGitHubSourceUpdatePayload =
PagesGitHubLatestSourceUpdatePayload | PagesGitHubTagSourceUpdatePayload;
/**
* Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant,
* 不向 Remote URL 或 GitHub Release payload 混入构建字段。
*/
export type PagesSourceUpdatePayload =
PagesRemoteSourceUpdatePayload | PagesGitHubSourceUpdatePayload;
export interface PagesSourceActionPayload {
confirmed_revision?: string;
}
@@ -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);
}
});
});
+59 -47
View File
@@ -5,51 +5,63 @@
package pages
const (
errPagesProjectNotFound = "pages 项目不存在"
errPagesSlugExists = "pages 项目标识已存在"
errPagesNameRequired = "pages 项目名称不能为空"
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "pages 部署不存在"
errPagesDeploymentMismatch = "pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageURLRequired = "请填写部署包下载链接"
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
errPagesPackageEmpty = "pages 部署包不能为空"
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesPackagePathEmpty = "pages 部署包路径为空"
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
errPagesInvalidSnapshotFormat = "配置快照格式无效"
errPagesActorMissing = "无法识别当前用户"
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
errPagesSourceNotFound = "pages 部署源不存在"
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
errPagesSourceTypeUnsupported = "当前阶段仅支持远程地址部署源"
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
errPagesSourceActionBusy = "pages 部署源任务正在执行"
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
errPagesSourceSyncFailed = "pages 部署源同步失败"
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
errPagesProjectNotFound = "pages 项目不存在"
errPagesSlugExists = "pages 项目标识已存在"
errPagesNameRequired = "pages 项目名称不能为空"
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "pages 部署不存在"
errPagesDeploymentMismatch = "pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageURLRequired = "请填写部署包下载链接"
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
errPagesPackageEmpty = "pages 部署包不能为空"
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesPackagePathEmpty = "pages 部署包路径为空"
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
errPagesInvalidSnapshotFormat = "配置快照格式无效"
errPagesActorMissing = "无法识别当前用户"
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
errPagesSourceNotFound = "pages 部署源不存在"
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名"
errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间"
errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭"
errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源"
errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效"
errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败"
errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本"
errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认"
errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查"
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
errPagesSourceActionBusy = "pages 部署源任务正在执行"
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
errPagesSourceSyncFailed = "pages 部署源同步失败"
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
)
@@ -0,0 +1,343 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"net/url"
"path"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
githubReleaseSelectorLatest = "latest"
githubReleaseSelectorTag = "tag"
githubSourceIdentityDomain = "openflare:pages:github-release:v2"
initialCheckRetryDelay = 5 * time.Minute
githubRepositoryPathParts = 2
githubCheckJitterRange = 301
githubCheckJitterCenter = 150
)
var (
githubOwnerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
)
type githubSourceConfig struct {
Repository string
Selector string
Tag string
AssetName string
CheckInterval int
SourceIdentity string
}
func validateGitHubSourceInput(input SourceUpdateInput) error {
if strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
return errors.New(errPagesSourceTypeUnsupported)
}
if input.RemoteURLSet || strings.TrimSpace(input.RemoteURL) != "" ||
strings.TrimSpace(input.RemoteNetworkPolicy) != "" {
return errors.New(errPagesSourceGitHubFields)
}
if input.AutoUpdateEnabled {
return errors.New(errPagesSourceAutoNotAvailable)
}
if _, err := normalizeGitHubRepositoryURL(input.RepositoryURL); err != nil {
return err
}
selector := strings.TrimSpace(input.ReleaseSelector)
if selector == "" {
selector = githubReleaseSelectorLatest
}
assetName := input.AssetName
if assetName == "" {
assetName = defaultGitHubAssetName
}
if !validGitHubAssetName(assetName) {
return errors.New(errPagesSourceAssetNameInvalid)
}
switch selector {
case githubReleaseSelectorLatest:
if input.ReleaseTag != "" {
return errors.New(errPagesSourceSelectorInvalid)
}
interval := input.CheckIntervalMinutes
if interval != 0 && (interval < minimumCheckInterval || interval > maximumCheckInterval) {
return errors.New(errPagesSourceCheckInterval)
}
case githubReleaseSelectorTag:
if !validGitHubReleaseTagConfig(input.ReleaseTag) || input.CheckIntervalMinutes != 0 {
return errors.New(errPagesSourceSelectorInvalid)
}
default:
return errors.New(errPagesSourceSelectorInvalid)
}
return nil
}
func buildGitHubSourceConfig(input SourceUpdateInput) (githubSourceConfig, error) {
repository, err := normalizeGitHubRepositoryURL(input.RepositoryURL)
if err != nil {
return githubSourceConfig{}, err
}
selector := strings.TrimSpace(input.ReleaseSelector)
if selector == "" {
selector = githubReleaseSelectorLatest
}
tag := input.ReleaseTag
assetName := input.AssetName
if assetName == "" {
assetName = defaultGitHubAssetName
}
interval := input.CheckIntervalMinutes
if selector == githubReleaseSelectorLatest && interval == 0 {
interval = defaultCheckInterval
}
return githubSourceConfig{
Repository: repository,
Selector: selector,
Tag: tag,
AssetName: assetName,
CheckInterval: interval,
SourceIdentity: buildGitHubSourceIdentity(repository, selector, tag, assetName),
}, nil
}
func buildGitHubSourceIdentity(repository, selector, tag, assetName string) string {
fields := [...]string{repository, selector, tag, assetName}
encoded := make([]byte, 0, len(githubSourceIdentityDomain)+len(fields)*8+
len(repository)+len(selector)+len(tag)+len(assetName))
encoded = append(encoded, githubSourceIdentityDomain...)
var fieldLength [8]byte
for _, field := range fields {
// Go strings hold the validated UTF-8 bytes used by GitHub. Prefixing each
// field with its byte length prevents delimiter characters from creating
// ambiguous identities across field boundaries.
binary.BigEndian.PutUint64(fieldLength[:], uint64(len(field)))
encoded = append(encoded, fieldLength[:]...)
encoded = append(encoded, field...)
}
identityHash := sha256.Sum256(encoded)
return hex.EncodeToString(identityHash[:])
}
func normalizeGitHubRepositoryURL(raw string) (string, error) {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, "github.com") ||
parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" ||
strings.Contains(raw, "#") ||
parsed.EscapedPath() != parsed.Path || !strings.HasPrefix(parsed.Path, "/") ||
strings.HasPrefix(parsed.Path, "//") || strings.HasSuffix(parsed.Path, "/") {
return "", errors.New(errPagesSourceRepositoryInvalid)
}
parts := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
if len(parts) != githubRepositoryPathParts {
return "", errors.New(errPagesSourceRepositoryInvalid)
}
owner := parts[0]
repository := parts[1]
repository = strings.TrimSuffix(repository, ".git")
if !githubOwnerPattern.MatchString(owner) || !githubRepoPattern.MatchString(repository) ||
len(repository) > 100 || repository == "." || repository == ".." {
return "", errors.New(errPagesSourceRepositoryInvalid)
}
return owner + "/" + repository, nil
}
func validGitHubReleaseTagConfig(value string) bool {
if !validGitHubReleaseDisplayTag(value) ||
strings.ContainsAny(value, " ~^:?*[\\") || strings.HasPrefix(value, "/") ||
strings.HasSuffix(value, "/") || strings.HasSuffix(value, ".") ||
strings.Contains(value, "//") || strings.Contains(value, "..") || strings.Contains(value, "@{") {
return false
}
for component := range strings.SplitSeq(value, "/") {
if strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
return false
}
}
return true
}
func validGitHubReleaseDisplayTag(value string) bool {
if value == "" || len(value) > 255 || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if unsafeGitHubInputRune(character) {
return false
}
}
return true
}
func validGitHubAssetName(value string) bool {
if value == "" || len(value) > 255 || !utf8.ValidString(value) ||
path.Base(value) != value || strings.Contains(value, "\\") ||
value == "." || value == ".." {
return false
}
for _, character := range value {
if unsafeGitHubInputRune(character) {
return false
}
}
return true
}
func unsafeGitHubInputRune(character rune) bool {
return unicode.IsControl(character) || character == '\u2028' || character == '\u2029' ||
character == '\u061c' || character == '\u200e' || character == '\u200f' ||
(character >= '\u202a' && character <= '\u202e') ||
(character >= '\u2066' && character <= '\u2069')
}
func updateGitHubSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
var project model.PagesProject
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
return false, err
}
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
if err != nil {
return false, err
}
config, err := buildGitHubSourceConfig(input)
if err != nil {
return false, err
}
if !hasExisting {
return true, createGitHubSourceTx(tx, projectID, config)
}
if !githubSourceConfigChanged(existing, config) {
return false, nil
}
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", existing.ID).First(&runtime).Error; err != nil {
return false, err
}
identityChanged := existing.SourceIdentity != config.SourceIdentity
if err := tx.Model(existing).Updates(githubSourceUpdates(config, existing.ConfigVersion+1)).Error; err != nil {
return false, err
}
if err := resetRuntimeAfterGitHubUpdate(tx, &runtime, config, identityChanged); err != nil {
return false, err
}
return true, nil
}
func createGitHubSourceTx(tx *gorm.DB, projectID uint, config githubSourceConfig) error {
source := &model.PagesProjectSource{
ProjectID: projectID,
SourceType: PagesSourceTypeGitHubRelease,
GitHubRepository: config.Repository,
ReleaseSelector: config.Selector,
ReleaseTag: config.Tag,
AssetName: config.AssetName,
AutoUpdateEnabled: false,
CheckIntervalMinutes: config.CheckInterval,
ConfigVersion: 1,
SourceIdentity: config.SourceIdentity,
}
if err := tx.Create(source).Error; err != nil {
return err
}
runtime := &model.PagesProjectSourceRuntime{SourceID: source.ID, SyncStatus: pagesSourceStatusIdle}
if config.Selector == githubReleaseSelectorLatest {
next := nextGitHubCheckAt(time.Now(), source.ID, config.CheckInterval)
runtime.NextCheckAt = &next
}
return tx.Create(runtime).Error
}
func githubSourceUpdates(config githubSourceConfig, version int) map[string]any {
return map[string]any{
"source_type": PagesSourceTypeGitHubRelease,
"remote_url": "",
"remote_network_policy": "",
"github_repository": config.Repository,
"release_selector": config.Selector,
"release_tag": config.Tag,
"asset_name": config.AssetName,
sourceColumnAutoUpdateEnabled: false,
"check_interval_minutes": config.CheckInterval,
sourceColumnConfigVersion: version,
"source_identity": config.SourceIdentity,
}
}
func githubSourceConfigChanged(existing *model.PagesProjectSource, config githubSourceConfig) bool {
return existing.SourceType != PagesSourceTypeGitHubRelease || existing.RemoteURL != "" ||
existing.RemoteNetworkPolicy != "" || existing.GitHubRepository != config.Repository ||
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled ||
existing.CheckIntervalMinutes != config.CheckInterval
}
func resetRuntimeAfterGitHubUpdate(
tx *gorm.DB,
runtime *model.PagesProjectSourceRuntime,
config githubSourceConfig,
identityChanged bool,
) error {
if err := resetRuntimeAfterSourceUpdate(tx, runtime, identityChanged); err != nil {
return err
}
var nextCheckAt any
if config.Selector == githubReleaseSelectorLatest {
next := nextGitHubCheckAt(time.Now(), runtime.SourceID, config.CheckInterval)
nextCheckAt = &next
}
return tx.Model(runtime).Update("next_check_at", nextCheckAt).Error
}
func nextGitHubCheckAt(now time.Time, sourceID uint, intervalMinutes int) time.Time {
// A stable, bounded offset avoids a thundering herd without persisting
// another scheduling field. Scanner Phase 3 reuses this calculation.
jitterSeconds := int64(sourceID%githubCheckJitterRange) - githubCheckJitterCenter
return now.Add(time.Duration(intervalMinutes)*time.Minute + time.Duration(jitterSeconds)*time.Second)
}
func markInitialCheckDispatchFailed(ctx context.Context, sourceID uint, configVersion int) {
updates := map[string]any{
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
sourceRuntimeColumnLastError: errPagesSourceInitialCheckWarning,
}
var source model.PagesProjectSource
if err := db.DB(ctx).Where("id = ? AND config_version = ?", sourceID, configVersion).First(&source).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ErrorF(ctx, "[PagesSource] load initial check source snapshot failed: source_id=%d error=%v", sourceID, err)
}
return
}
if source.ReleaseSelector == githubReleaseSelectorLatest {
next := time.Now().Add(initialCheckRetryDelay)
updates["next_check_at"] = &next
}
now := time.Now()
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", sourceID).
Where("lease_expires_at IS NULL OR lease_expires_at <= ?", now).
Where("EXISTS (SELECT 1 FROM of_pages_project_sources source WHERE source.id = ? AND source.config_version = ?)", sourceID, configVersion).
Updates(updates)
if result.Error != nil {
logger.ErrorF(ctx, "[PagesSource] mark initial check dispatch failure: source_id=%d error=%v", sourceID, result.Error)
}
}
@@ -0,0 +1,724 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const githubSourceDetailProvider = "github"
var githubDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
type githubSourceProviderDomainError struct {
message string
permanent bool
retryAt *time.Time
}
func (domainError *githubSourceProviderDomainError) Error() string {
return domainError.message
}
type githubReleaseAPI interface {
Resolve(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
Download(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
}
var newGitHubReleaseClient = func() githubReleaseAPI {
return githubrelease.NewClient()
}
type githubSourceTarget struct {
Revision string
Detail sourceDetail
DetailJSON string
Release githubrelease.Release
Asset githubrelease.Asset
RetryAt *time.Time
}
type githubCheckTaskResult struct {
Message string
Detail string
Stale bool
}
type preparedGitHubSource struct {
target *githubSourceTarget
download *githubrelease.DownloadResult
format pagesarchive.Format
manifest *deploymentManifest
ingestState *sourceIngestState
limits pagesLimits
}
func checkGitHubSource(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
) (*githubCheckTaskResult, error) {
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease {
return nil, errors.New(errPagesSourceTypeUnsupported)
}
task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
client := newGitHubReleaseClient()
result, err := client.Resolve(ctx, githubrelease.ResolveRequest{
Repository: snapshot.GitHubRepository,
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
Tag: snapshot.ReleaseTag,
AssetName: snapshot.AssetName,
ETag: snapshot.ETag,
})
if err != nil {
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
retryAt, _ := githubrelease.RetryAt(err)
domainErr := githubSourceDomainError(err)
if failErr := failGitHubCheckLease(ctx, snapshot, domainErr.Error(), retryAt); failErr != nil {
if errors.Is(failErr, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, failErr
}
return nil, domainErr
}
if result.NotModified {
if err := finishGitHubCheckNotModified(ctx, snapshot, result); err != nil {
if errors.Is(err, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, err
}
return &githubCheckTaskResult{Message: "GitHub Release 检查完成,内容未变化"}, nil
}
target, err := buildGitHubSourceTarget(result.Release, result.Asset, result.RetryAt)
if err != nil {
retryAt := time.Time{}
if result.RetryAt != nil {
retryAt = result.RetryAt.UTC()
}
if failErr := failGitHubCheckLease(ctx, snapshot, err.Error(), retryAt); failErr != nil {
if errors.Is(failErr, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, failErr
}
return nil, err
}
status, err := finishGitHubCheckTarget(ctx, snapshot, result, target)
if err != nil {
if errors.Is(err, errSourceFinalFence) {
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
}
return nil, err
}
detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status})
message := "GitHub Release 检查完成"
switch status {
case pagesSourceStatusUpdateAvailable:
message = "发现新的 GitHub Release 部署包"
case pagesSourceStatusAttention:
message = "检测到同一 Release 的资源被替换,需要确认"
}
return &githubCheckTaskResult{Message: message, Detail: string(detail)}, nil
}
func buildGitHubSourceTarget(
release githubrelease.Release,
asset githubrelease.Asset,
retryAt *time.Time,
) (*githubSourceTarget, error) {
digest := strings.ToLower(strings.TrimSpace(asset.Digest))
if digest != "" && !githubDigestPattern.MatchString(digest) {
return nil, errors.New(errPagesSourceDigestInvalid)
}
if strings.TrimSpace(release.ID) == "" || strings.TrimSpace(asset.ID) == "" ||
!validGitHubReleaseDisplayTag(release.Tag) || !validGitHubAssetName(asset.Name) ||
asset.State != "uploaded" || asset.UpdatedAt.IsZero() {
return nil, errors.New(errPagesSourceReleaseNotFound)
}
updatedAt := asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
rawRevision := "github:" + release.ID + ":" + asset.ID + ":" + updatedAt + ":" + digest
sum := sha256.Sum256([]byte(rawRevision))
detail := sourceDetail{
Provider: githubSourceDetailProvider,
Tag: release.Tag,
AssetName: asset.Name,
ReleaseID: release.ID,
AssetID: asset.ID,
AssetUpdatedAt: updatedAt,
Digest: digest,
}
detailJSON, err := json.Marshal(detail)
if err != nil {
return nil, errors.New(errPagesSourceSyncFailed)
}
return &githubSourceTarget{
Revision: hex.EncodeToString(sum[:]),
Detail: detail,
DetailJSON: string(detailJSON),
Release: release,
Asset: asset,
RetryAt: retryAt,
}, nil
}
func finishGitHubCheckNotModified(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
result githubrelease.ResolveResult,
) error {
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
if err != nil {
return err
}
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
updates["etag"] = result.ETag
updates[sourceRuntimeColumnSyncStatus] = normalizedSourceRuntimeStatus(runtime)
return tx.Model(runtime).Updates(updates).Error
})
}
func finishGitHubCheckTarget(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
result githubrelease.ResolveResult,
target *githubSourceTarget,
) (string, error) {
status := pagesSourceStatusIdle
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
if err != nil {
return err
}
status = targetRuntimeStatus(target, runtime.LastAppliedRevision, runtime.LastAppliedDetail)
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
updates["etag"] = result.ETag
updates["last_seen_revision"] = target.Revision
updates["last_seen_detail"] = target.DetailJSON
updates[sourceRuntimeColumnSyncStatus] = status
return tx.Model(runtime).Updates(updates).Error
})
return status, err
}
func githubCheckTerminalUpdates(
snapshot *sourceExecutionSnapshot,
now time.Time,
retryAt *time.Time,
) map[string]any {
updates := map[string]any{
sourceRuntimeColumnLastError: "",
sourceRuntimeColumnLastCheckedAt: &now,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
}
updates["next_check_at"] = nextCheckAfterGitHubResponse(snapshot, now, retryAt)
return updates
}
func nextCheckAfterGitHubResponse(
snapshot *sourceExecutionSnapshot,
now time.Time,
retryAt *time.Time,
) any {
if snapshot.ReleaseSelector != githubReleaseSelectorLatest {
return nil
}
next := nextGitHubCheckAt(now, snapshot.SourceID, snapshot.CheckIntervalMinutes)
if retryAt != nil && retryAt.After(next) {
next = retryAt.UTC()
}
return &next
}
func lockOwnedSourceRuntime(
tx *gorm.DB,
snapshot *sourceExecutionSnapshot,
) (*model.PagesProjectSourceRuntime, time.Time, error) {
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", snapshot.SourceID).First(&runtime).Error; err != nil {
return nil, time.Time{}, err
}
now := time.Now()
if runtime.LeaseToken != snapshot.LeaseToken || runtime.LeaseExpiresAt == nil ||
!runtime.LeaseExpiresAt.After(now) {
return nil, time.Time{}, errSourceFinalFence
}
return &runtime, now, nil
}
func failGitHubCheckLease(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
message string,
retryAt time.Time,
) error {
now := time.Now()
next := now.Add(initialCheckRetryDelay)
if retryAt.After(next) {
next = retryAt.UTC()
}
updates := map[string]any{
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
sourceRuntimeColumnLastError: safeSourceRuntimeError(message),
sourceRuntimeColumnLastCheckedAt: &now,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
}
if snapshot.ReleaseSelector == githubReleaseSelectorLatest {
updates["next_check_at"] = &next
} else {
updates["next_check_at"] = nil
}
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errSourceFinalFence
}
return nil
}
func targetRuntimeStatus(
target *githubSourceTarget,
appliedRevision string,
appliedDetail string,
) string {
if target == nil || target.Revision == appliedRevision {
return pagesSourceStatusIdle
}
applied := sourceDetail{}
if unmarshalSourceDetail(appliedDetail, &applied) == nil && target.Detail.ReleaseID != "" &&
target.Detail.ReleaseID == applied.ReleaseID {
return pagesSourceStatusAttention
}
return pagesSourceStatusUpdateAvailable
}
func preflightGitHubSyncConfirmation(ctx context.Context, sourceID uint, confirmedRevision string) error {
var runtime model.PagesProjectSourceRuntime
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
return err
}
replacement := sourceHasSameReleaseReplacement(&runtime)
if replacement && confirmedRevision == "" {
return errors.New(errPagesSourceConfirmationNeeded)
}
if confirmedRevision != "" && (!replacement || confirmedRevision != runtime.LastSeenRevision) {
return errors.New(errPagesSourceConfirmationStale)
}
return nil
}
func syncGitHubSource(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
actor string,
targetRevision string,
confirmedRevision string,
) (outcome *sourceSyncOutcome, resultErr error) {
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease || !validPagesSourceActor(actor) {
return nil, errors.New(errPagesSourceActionInvalid)
}
defer func() {
resultErr = finalizeGitHubSyncFailure(ctx, snapshot, resultErr)
}()
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
ctx, snapshot, pagesSourceSyncLeaseDuration, pagesSourceHeartbeatInterval,
)
if err != nil {
return sourceHeartbeatOutcome(err)
}
defer func() { _ = heartbeat.stop() }()
client := newGitHubReleaseClient()
target, guardedOutcome, err := resolveAndGuardGitHubSync(
workCtx, client, snapshot, targetRevision, confirmedRevision,
)
if err != nil {
return nil, err
}
if guardedOutcome != nil {
return guardedOutcome, nil
}
prepared, err := prepareGitHubSyncPackage(workCtx, client, snapshot, target)
if err != nil {
return nil, err
}
defer func() {
if cleanupErr := prepared.download.Cleanup(); cleanupErr != nil {
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
}
}()
defer compensateSourceIngest(ctx, snapshot, prepared.ingestState)
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
return sourceHeartbeatOutcome(heartbeatErr)
}
renewed, err := renewSourceLease(ctx, snapshot, pagesSourceSyncLeaseDuration)
if err != nil {
return nil, err
}
if !renewed {
return &sourceSyncOutcome{Stale: true}, nil
}
return activatePreparedGitHubSource(ctx, snapshot, actor, prepared)
}
func finalizeGitHubSyncFailure(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
resultErr error,
) error {
if resultErr == nil {
return nil
}
cleanupCtx, cancel := sourceCleanupContext(ctx)
defer cancel()
finalizerErr := persistGitHubSyncFailure(cleanupCtx, snapshot, resultErr)
if finalizerErr == nil {
return resultErr
}
logger.WarnF(
cleanupCtx,
"[PagesSource] finalize GitHub sync failure failed: source_id=%d source_error=%s error=%v",
snapshot.SourceID, safeGitHubSourceError(resultErr), finalizerErr,
)
// final fence 丢失表示已有新任务接管 runtime,不应覆盖;数据库
// finalizer 失败则保持可重试,避免继承永久错误或 provider deadline 分类。
if errors.Is(finalizerErr, errSourceFinalFence) {
return resultErr
}
return errors.New(errPagesSourceSyncFailed)
}
func persistGitHubSyncFailure(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
resultErr error,
) error {
var domainError *githubSourceProviderDomainError
if errors.As(resultErr, &domainError) && domainError.retryAt != nil {
return failGitHubCheckLease(ctx, snapshot, domainError.message, *domainError.retryAt)
}
return failSourceLease(ctx, snapshot, safeGitHubSourceError(resultErr))
}
func activatePreparedGitHubSource(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
actor string,
prepared *preparedGitHubSource,
) (*sourceSyncOutcome, error) {
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
deployment, reused, referenced, err := commitSourceDeployment(
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
prepared.target.Detail, prepared.target.DetailJSON, actor, prepared.manifest,
prepared.ingestState.Result, prepared.ingestState.HasIngest, prepared.target.RetryAt,
)
prepared.ingestState.Referenced = referenced
if errors.Is(err, errSourceFinalFence) {
return &sourceSyncOutcome{Stale: true}, nil
}
if err != nil {
return nil, err
}
prepared.ingestState.Referenced = prepared.ingestState.HasIngest && deployment.UploadID == prepared.ingestState.Result.Upload.ID
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, prepared.limits.HistoryCount, 0); pruneErr != nil {
logger.ErrorF(ctx, "[PagesSource] strict prune failed after GitHub sync: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, pruneErr)
}
view := buildDeploymentView(deployment)
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
}
func resolveAndGuardGitHubSync(
ctx context.Context,
client githubReleaseAPI,
snapshot *sourceExecutionSnapshot,
targetRevision string,
confirmedRevision string,
) (*githubSourceTarget, *sourceSyncOutcome, error) {
task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{
Repository: snapshot.GitHubRepository,
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
Tag: snapshot.ReleaseTag,
AssetName: snapshot.AssetName,
})
if err != nil {
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
return nil, nil, githubSourceDomainError(err)
}
if resolved.NotModified {
return nil, nil, errors.New(errPagesSourceReleaseNotFound)
}
target, err := buildGitHubSourceTarget(resolved.Release, resolved.Asset, resolved.RetryAt)
if err != nil {
return nil, nil, &githubSourceProviderDomainError{
message: safeGitHubSourceError(err),
permanent: isPermanentSourceSyncError(err),
retryAt: resolved.RetryAt,
}
}
guardedOutcome, err := guardGitHubSyncTarget(ctx, snapshot, target, targetRevision, confirmedRevision)
return target, guardedOutcome, err
}
func guardGitHubSyncTarget(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
targetRevision string,
confirmedRevision string,
) (*sourceSyncOutcome, error) {
status := targetRuntimeStatus(target, snapshot.LastAppliedRevision, snapshot.LastAppliedDetail)
if targetRevision != "" && targetRevision != target.Revision {
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, "", true, true)
}
if confirmedRevision != "" && (confirmedRevision != snapshot.LastSeenRevision || confirmedRevision != target.Revision) {
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationStale, false, false)
}
if status == pagesSourceStatusAttention && confirmedRevision != target.Revision {
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationNeeded, false, false)
}
if confirmedRevision != "" && status != pagesSourceStatusAttention {
return nil, errors.New(errPagesSourceConfirmationStale)
}
return nil, nil
}
func releaseGuardedGitHubTarget(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
status string,
lastError string,
expedite bool,
staleSuccess bool,
) (*sourceSyncOutcome, error) {
err := releaseGitHubSyncWithoutActivation(ctx, snapshot, target, status, lastError, expedite, target.RetryAt)
if errors.Is(err, errSourceFinalFence) || (err == nil && staleSuccess) {
return &sourceSyncOutcome{Stale: true}, nil
}
if err != nil {
return nil, err
}
return nil, errors.New(lastError)
}
func prepareGitHubSyncPackage(
ctx context.Context,
client githubReleaseAPI,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
) (*preparedGitHubSource, error) {
limits := resolvePagesLimits(ctx)
task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
download, err := client.Download(ctx, githubrelease.DownloadRequest{
Repository: snapshot.GitHubRepository,
Asset: target.Asset,
MaxBytes: limits.PackageBytes,
})
if err != nil {
logger.WarnF(ctx, "[PagesSource] GitHub download failed: source_id=%d repo=%s asset=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, snapshot.AssetName, err)
return nil, githubSourceDomainError(err)
}
prepared, err := inspectAndIngestGitHubPackage(ctx, snapshot, target, download, limits)
if err != nil {
if cleanupErr := download.Cleanup(); cleanupErr != nil {
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package after preparation failure failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
}
return nil, err
}
return prepared, nil
}
func inspectAndIngestGitHubPackage(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
download *githubrelease.DownloadResult,
limits pagesLimits,
) (*preparedGitHubSource, error) {
if download.SHA256 == "" || download.Path == "" {
return nil, errors.New(errPagesSourceSyncFailed)
}
if target.Detail.Digest != "" && "sha256:"+download.SHA256 != target.Detail.Digest {
return nil, errors.New(errPagesSourceDigestMismatch)
}
format, ok := pagesarchive.DetectFormatFromName(target.Asset.Name)
var err error
if !ok {
format, _, err = detectRemoteSourceFormat(download.Path, target.Asset.Name, "")
if err != nil {
return nil, err
}
}
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
if err != nil {
return nil, err
}
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
if err != nil {
return nil, err
}
task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口")
manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits)
if err != nil {
return nil, err
}
ingestState, err := resolveGitHubSourceIngest(ctx, snapshot, target, download, format)
if err != nil {
return nil, err
}
return &preparedGitHubSource{
target: target, download: download, format: format,
manifest: manifest, ingestState: ingestState, limits: limits,
}, nil
}
func resolveGitHubSourceIngest(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
download *githubrelease.DownloadResult,
format pagesarchive.Format,
) (*sourceIngestState, error) {
if _, err := findSourceDeployment(ctx, snapshot.ProjectID, snapshot.SourceIdentity, target.Revision); err == nil {
return &sourceIngestState{}, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包")
result, err := ingestPagesDeploymentPackageWithSource(
ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format,
)
if err != nil {
return nil, err
}
return &sourceIngestState{Result: result, HasIngest: true}, nil
}
func releaseGitHubSyncWithoutActivation(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
target *githubSourceTarget,
status string,
lastError string,
expedite bool,
retryAt *time.Time,
) error {
now := time.Now()
nextCheckAt := nextCheckAfterGitHubResponse(snapshot, now, retryAt)
if expedite && snapshot.ReleaseSelector == githubReleaseSelectorLatest {
next := now.Add(initialCheckRetryDelay)
if retryAt != nil && retryAt.After(next) {
next = retryAt.UTC()
}
nextCheckAt = &next
}
updates := map[string]any{
"last_seen_revision": target.Revision,
"last_seen_detail": target.DetailJSON,
sourceRuntimeColumnSyncStatus: status,
sourceRuntimeColumnLastError: lastError,
sourceRuntimeColumnLastCheckedAt: &now,
"next_check_at": nextCheckAt,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
}
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errSourceFinalFence
}
return nil
}
func safeGitHubSourceError(err error) string {
if err == nil {
return errPagesSourceSyncFailed
}
message := strings.TrimSpace(err.Error())
for _, safeMessage := range []string{
errPagesSourceSyncFailed,
errPagesSourceReleaseNotFound,
errPagesSourceDigestInvalid,
errPagesSourceDigestMismatch,
errPagesSourceConfirmationNeeded,
errPagesSourceConfirmationStale,
errPagesPackageURLTooLarge,
errPagesPackageEmpty,
errPagesPackageUnsupported,
errPagesPackageInvalid,
errPagesPackageExtractedTooLarge,
errPagesPackageFileTooLarge,
errPagesEntryFileMissing,
} {
if message == safeMessage {
return safeMessage
}
}
return errPagesSourceSyncFailed
}
func githubSourceDomainError(err error) error {
message := errPagesSourceSyncFailed
retryAt, hasRetryAt := githubrelease.RetryAt(err)
var retryDeadline *time.Time
if hasRetryAt {
retryDeadline = &retryAt
}
if err == nil {
return &githubSourceProviderDomainError{message: message, permanent: false}
}
if githubrelease.IsDigestError(err) {
message = errPagesSourceDigestMismatch
return &githubSourceProviderDomainError{message: message, permanent: true}
}
if githubrelease.IsNotFound(err) {
message = errPagesSourceReleaseNotFound
return &githubSourceProviderDomainError{message: message, permanent: true}
}
if errors.Is(err, githubrelease.ErrAssetTooLarge) {
message = errPagesPackageURLTooLarge
return &githubSourceProviderDomainError{message: message, permanent: true}
}
if errors.Is(err, githubrelease.ErrEmptyAsset) {
message = errPagesPackageEmpty
return &githubSourceProviderDomainError{message: message, permanent: true}
}
return &githubSourceProviderDomainError{
message: message, permanent: !githubrelease.IsRetryable(err), retryAt: retryDeadline,
}
}
func shouldSkipGitHubActionRetry(err error) bool {
var domainError *githubSourceProviderDomainError
return errors.As(err, &domainError) && (domainError.permanent || domainError.retryAt != nil)
}
@@ -0,0 +1,111 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
)
func TestGitHubSourceIdentityLengthPrefixesFieldsAndResetsRuntime(t *testing.T) {
firstInput := SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/OpenFlare/site",
ReleaseSelector: githubReleaseSelectorTag,
ReleaseTag: "release|foo",
AssetName: "bar.zip",
}
secondInput := SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/OpenFlare/site",
ReleaseSelector: githubReleaseSelectorTag,
ReleaseTag: "release",
AssetName: "foo|bar.zip",
}
firstConfig, err := buildGitHubSourceConfig(firstInput)
if err != nil {
t.Fatalf("buildGitHubSourceConfig(first) error = %v, want nil", err)
}
secondConfig, err := buildGitHubSourceConfig(secondInput)
if err != nil {
t.Fatalf("buildGitHubSourceConfig(second) error = %v, want nil", err)
}
legacyIdentityInput := func(config githubSourceConfig) string {
return "github|" + config.Repository + "|" + config.Selector + "|" +
config.Tag + "|" + config.AssetName
}
if firstLegacy, secondLegacy := legacyIdentityInput(firstConfig), legacyIdentityInput(secondConfig); firstLegacy != secondLegacy {
t.Fatalf("legacy identity inputs differ: %q != %q; collision fixture is invalid", firstLegacy, secondLegacy)
}
if firstConfig.SourceIdentity == secondConfig.SourceIdentity {
t.Fatalf("length-prefixed identities collide: %q", firstConfig.SourceIdentity)
}
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-identity-collision")
firstSource, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, firstInput)
if got, want := firstSource.SourceIdentity, firstConfig.SourceIdentity; got != want {
t.Fatalf("first source identity = %q, want %q", got, want)
}
checkedAt := time.Now().Add(-time.Minute)
syncedAt := time.Now().Add(-30 * time.Second)
nextCheckAt := time.Now().Add(time.Hour)
leaseExpiresAt := time.Now().Add(time.Minute)
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", firstSource.ID).
Updates(map[string]any{
"etag": `"old-etag"`,
"last_seen_revision": strings.Repeat("a", 64),
"last_seen_detail": `{"provider":"github_release","tag":"release|foo"}`,
"last_applied_revision": strings.Repeat("b", 64),
"last_applied_detail": `{"provider":"github_release","tag":"older"}`,
"sync_status": pagesSourceStatusSyncing,
"last_error": "old error",
"last_checked_at": &checkedAt,
"last_synced_at": &syncedAt,
"next_check_at": &nextCheckAt,
"lease_expires_at": &leaseExpiresAt,
"lease_token": "old-lease",
}).Error; err != nil {
t.Fatalf("seed runtime cursors error = %v, want nil", err)
}
secondSource, runtime := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, secondInput)
if secondSource.ID != firstSource.ID {
t.Errorf("updated source ID = %d, want unchanged %d", secondSource.ID, firstSource.ID)
}
if got, want := secondSource.SourceIdentity, secondConfig.SourceIdentity; got != want {
t.Errorf("updated source identity = %q, want %q", got, want)
}
if got, want := secondSource.ConfigVersion, firstSource.ConfigVersion+1; got != want {
t.Errorf("updated source config version = %d, want %d", got, want)
}
if runtime.ETag != "" || runtime.LastSeenRevision != "" || runtime.LastSeenDetail != "" ||
runtime.LastAppliedRevision != "" || runtime.LastAppliedDetail != "" {
t.Errorf("identity change retained runtime cursors: %+v", runtime)
}
if runtime.LastCheckedAt != nil || runtime.LastSyncedAt != nil || runtime.NextCheckAt != nil {
t.Errorf(
"identity change retained runtime timestamps: checked=%v synced=%v next=%v",
runtime.LastCheckedAt,
runtime.LastSyncedAt,
runtime.NextCheckAt,
)
}
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" ||
runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
t.Errorf(
"identity change retained runtime state: status=%q error=%q lease=(%q, %v)",
runtime.SyncStatus,
runtime.LastError,
runtime.LeaseToken,
runtime.LeaseExpiresAt,
)
}
}
@@ -0,0 +1,888 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/hibiken/asynq"
"gorm.io/gorm"
)
type fakeGitHubReleaseClient struct {
resolve func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
download func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
}
func (client *fakeGitHubReleaseClient) Resolve(
ctx context.Context,
request githubrelease.ResolveRequest,
) (githubrelease.ResolveResult, error) {
return client.resolve(ctx, request)
}
func (client *fakeGitHubReleaseClient) Download(
ctx context.Context,
request githubrelease.DownloadRequest,
) (*githubrelease.DownloadResult, error) {
return client.download(ctx, request)
}
func useFakeGitHubReleaseClient(t *testing.T, client githubReleaseAPI) {
t.Helper()
previous := newGitHubReleaseClient
newGitHubReleaseClient = func() githubReleaseAPI { return client }
t.Cleanup(func() { newGitHubReleaseClient = previous })
}
func mustConfigureGitHubSourceWithoutDispatch(
t *testing.T,
ctx context.Context,
projectID uint,
input SourceUpdateInput,
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
t.Helper()
if err := validateGitHubSourceInput(input); err != nil {
t.Fatalf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
}
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
_, err := updateGitHubSourceTx(tx, projectID, input)
return err
}); err != nil {
t.Fatalf("updateGitHubSourceTx(project=%d) error = %v, want nil", projectID, err)
}
return mustLoadPagesSource(t, ctx, projectID)
}
func mustLoadPagesSource(
t *testing.T,
ctx context.Context,
projectID uint,
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
t.Helper()
var source model.PagesProjectSource
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
t.Fatalf("load source for project %d error = %v, want nil", projectID, err)
}
var runtime model.PagesProjectSourceRuntime
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
t.Fatalf("load runtime for source %d error = %v, want nil", source.ID, err)
}
return &source, &runtime
}
func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
ctx := setupPagesSourceTest(t)
setupPagesSourceDispatchTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-config")
input := SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/OpenFlare/site.git",
}
result, err := UpdateSourceAs(ctx, project.ID, input, "user:42")
if err != nil {
t.Fatalf("UpdateSourceAs(GitHub) error = %v, want nil", err)
}
if result.CheckTask == nil || result.CheckTask.Action != sourceActionCheck || result.Warning != "" {
t.Errorf("UpdateSourceAs(GitHub) result = %+v, want initial check receipt without warning", result)
}
execution, err := model.GetTaskExecutionByTaskID(ctx, result.CheckTask.TaskID)
if err != nil {
t.Fatalf("GetTaskExecutionByTaskID(%q) error = %v, want nil", result.CheckTask.TaskID, err)
}
var actionPayload SourceActionPayload
if err := json.Unmarshal([]byte(execution.Payload), &actionPayload); err != nil {
t.Fatalf("json.Unmarshal(initial check payload) error = %v, want nil", err)
}
if actionPayload.Actor != "user:42" || actionPayload.Action != sourceActionCheck ||
actionPayload.TargetRevision != "" || actionPayload.ConfirmedRevision != "" {
t.Errorf("initial check payload = %+v, want real actor and credential-free check", actionPayload)
}
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
if got, want := source.GitHubRepository, "OpenFlare/site"; got != want {
t.Errorf("GitHubRepository = %q, want %q", got, want)
}
if got, want := source.ReleaseSelector, githubReleaseSelectorLatest; got != want {
t.Errorf("ReleaseSelector = %q, want %q", got, want)
}
if got, want := source.AssetName, defaultGitHubAssetName; got != want {
t.Errorf("AssetName = %q, want %q", got, want)
}
if got, want := source.CheckIntervalMinutes, defaultCheckInterval; got != want {
t.Errorf("CheckIntervalMinutes = %d, want %d", got, want)
}
if got, want := source.SourceIdentity, "dbbd25307aaa3b88bc25353476940a049428655bd8421ac63045fdcb5fb23c9d"; got != want {
t.Errorf("SourceIdentity = %q, want %q", got, want)
}
if runtime.NextCheckAt == nil {
t.Error("GitHub latest NextCheckAt = nil, want scheduled value")
}
var taskCount int64
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&taskCount).Error; err != nil {
t.Fatalf("count initial checks error = %v, want nil", err)
}
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
t.Fatalf("UpdateSourceAs(GitHub no-op) error = %v, want nil", err)
}
var noOpTaskCount int64
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&noOpTaskCount).Error; err != nil {
t.Fatalf("count no-op checks error = %v, want nil", err)
}
if noOpTaskCount != taskCount {
t.Errorf("no-op initial check count = %d, want unchanged %d", noOpTaskCount, taskCount)
}
secret := "provider-switch-secret"
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURLSet: true,
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
}); err != nil {
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
}
remote, _ := mustLoadPagesSource(t, ctx, project.ID)
if remote.GitHubRepository != "" || remote.ReleaseSelector != "" || remote.AssetName != "" ||
remote.AutoUpdateEnabled || remote.CheckIntervalMinutes != 0 {
t.Errorf("Remote switched source retained GitHub fields: %+v", remote)
}
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
t.Fatalf("UpdateSourceAs(Remote to GitHub) error = %v, want nil", err)
}
github, _ := mustLoadPagesSource(t, ctx, project.ID)
if github.RemoteURL != "" || github.RemoteNetworkPolicy != "" {
t.Errorf("GitHub switched source retained Remote fields: URL=%q policy=%q", github.RemoteURL, github.RemoteNetworkPolicy)
}
}
func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-dispatch-warning")
result, err := UpdateSourceAs(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
}, "user:9")
if err != nil {
t.Fatalf("UpdateSourceAs(dispatch failure) error = %v, want saved source with warning", err)
}
if result.CheckTask != nil || result.Warning != errPagesSourceInitialCheckWarning {
t.Errorf("UpdateSourceAs(dispatch failure) result = %+v, want warning and nil check task", result)
}
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
if source.GitHubRepository != "a/b" || runtime.SyncStatus != pagesSourceStatusFailed ||
runtime.LastError != errPagesSourceInitialCheckWarning {
t.Errorf("saved source/runtime = repo:%q status:%q error:%q", source.GitHubRepository, runtime.SyncStatus, runtime.LastError)
}
}
func TestGitHubSourceRejectsUnsafeOrPhaseThreeFields(t *testing.T) {
tests := []SourceUpdateInput{
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "http://github.com/a/b"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a%20b/repo"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/extra"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com//a/b"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b?"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b#"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\n.zip"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\u202e.zip"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dir/dist.zip"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AutoUpdateEnabled: true},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", CheckIntervalMinutes: 60},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: " v1"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\n"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\u2028draft"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: `v1\draft`},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release//v1"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/.draft"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/v1.lock"},
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "latest", ReleaseTag: "v1"},
}
for _, input := range tests {
if err := validateGitHubSourceInput(input); err == nil {
t.Errorf("validateGitHubSourceInput(%+v) error = nil, want non-nil", input)
}
}
}
func TestGitHubSourceAcceptsLegalAssetAndTagCharacters(t *testing.T) {
tests := []SourceUpdateInput{
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: "dist?channel=stable&part#1.zip",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: " dist.zip ",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
ReleaseSelector: "tag",
ReleaseTag: "release/v1#stable&build=1",
AssetName: "dist.zip",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b.git",
ReleaseSelector: "tag",
ReleaseTag: "@",
AssetName: "dist.zip",
},
{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
ReleaseSelector: "tag",
ReleaseTag: "release/v1.LOCK",
AssetName: "dist.zip",
},
}
for _, input := range tests {
if err := validateGitHubSourceInput(input); err != nil {
t.Errorf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
}
}
}
func TestInitialCheckFailureUsesExactConfigFence(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-initial-fence")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
staleVersion := source.ConfigVersion
if err := db.DB(ctx).Model(source).Update("config_version", staleVersion+1).Error; err != nil {
t.Fatalf("increment source config version error = %v, want nil", err)
}
markInitialCheckDispatchFailed(ctx, source.ID, staleVersion)
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" {
t.Errorf("stale initial failure runtime = status:%q error:%q, want unchanged idle", runtime.SyncStatus, runtime.LastError)
}
}
func TestGitHubCheckUsesETagAndDetectsSameReleaseReplacement(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-check")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
appliedRevision := strings.Repeat("a", 64)
appliedDetail := `{"provider":"github","release_id":"100","asset_id":"1","tag":"release/v1","asset_name":"dist.zip"}`
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"etag": `"old-etag"`,
"last_applied_revision": appliedRevision,
"last_applied_detail": appliedDetail,
}).Error; err != nil {
t.Fatalf("seed GitHub runtime error = %v, want nil", err)
}
updatedAt := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
var gotETag string
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
gotETag = request.ETag
return githubrelease.ResolveResult{
ETag: `"new-etag"`,
Release: githubrelease.Release{ID: "100", Tag: "release/v1"},
Asset: githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt},
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called during check, want resolve only")
return nil, nil
},
})
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
if err != nil || outcome != sourceLeaseAcquired {
t.Fatalf("acquire check lease = (%+v, %q, %v), want acquired", snapshot, outcome, err)
}
result, err := checkGitHubSource(ctx, snapshot)
if err != nil {
t.Fatalf("checkGitHubSource() error = %v, want nil", err)
}
if result.Stale {
t.Error("checkGitHubSource() stale = true, want false")
}
if got, want := gotETag, `"old-etag"`; got != want {
t.Errorf("Resolve ETag = %q, want %q", got, want)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusAttention || runtime.LastSeenRevision == "" {
t.Errorf("replacement runtime = status:%q seen:%q, want attention with revision", runtime.SyncStatus, runtime.LastSeenRevision)
}
view, err := GetSource(ctx, project.ID)
if err != nil {
t.Fatalf("GetSource() error = %v, want nil", err)
}
if view.LastSeen == nil || view.LastSeen.Label != "release/v1" {
t.Errorf("LastSeen = %+v, want full tag with slash", view.LastSeen)
}
if err := preflightGitHubSyncConfirmation(ctx, source.ID, ""); err == nil || err.Error() != errPagesSourceConfirmationNeeded {
t.Errorf("preflight without confirmation error = %v, want %q", err, errPagesSourceConfirmationNeeded)
}
if err := preflightGitHubSyncConfirmation(ctx, source.ID, runtime.LastSeenRevision); err != nil {
t.Errorf("preflight exact confirmation error = %v, want nil", err)
}
}
func TestGitHubCheckNotModifiedRefreshesRuntimeWithoutDeployment(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-304")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{NotModified: true, ETag: `"same"`}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called for 304 check")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
if _, err := checkGitHubSource(ctx, snapshot); err != nil {
t.Fatalf("checkGitHubSource(304) error = %v, want nil", err)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.ETag != `"same"` || runtime.LastCheckedAt == nil || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
t.Errorf("304 runtime = %+v, want refreshed timestamps/etag and released lease", runtime)
}
var deployments int64
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
t.Fatalf("count deployments error = %v, want nil", err)
}
if deployments != 0 {
t.Errorf("deployments after check = %d, want 0", deployments)
}
}
func TestGitHubTargetMismatchPreservesAttentionAndExpeditesRecheck(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-target-mismatch")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
appliedRevision := strings.Repeat("a", 64)
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"last_applied_revision": appliedRevision,
"last_applied_detail": `{"provider":"github","release_id":"100","asset_id":"1","tag":"v1","asset_name":"dist.zip"}`,
}).Error; err != nil {
t.Fatalf("seed applied runtime error = %v, want nil", err)
}
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{
Release: githubrelease.Release{ID: "100", Tag: "v1"},
Asset: githubrelease.Asset{
ID: "2", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC),
},
RetryAt: &retryAt,
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after target mismatch")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
outcome, err := syncGitHubSource(ctx, snapshot, pagesSourceCreatedBySystem, strings.Repeat("b", 64), "")
if err != nil {
t.Fatalf("syncGitHubSource(target mismatch) error = %v, want nil stale outcome", err)
}
if outcome == nil || !outcome.Stale {
t.Errorf("syncGitHubSource(target mismatch) = %+v, want stale", outcome)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusAttention {
t.Errorf("target mismatch SyncStatus = %q, want %q", runtime.SyncStatus, pagesSourceStatusAttention)
}
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
t.Errorf("target mismatch NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
}
var deployments int64
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
t.Fatalf("count mismatch deployments error = %v, want nil", err)
}
if deployments != 0 {
t.Errorf("target mismatch deployments = %d, want 0", deployments)
}
}
func TestGitHubCheckLostLeaseReturnsStaleWithoutOverwritingRuntime(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-check-fence")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{}, errors.New("transient provider failure")
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
return nil, errors.New("unexpected")
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"lease_token": "new-owner",
"lease_expires_at": time.Now().Add(time.Minute),
"sync_status": pagesSourceStatusSyncing,
"last_error": "new-owner-state",
}).Error; err != nil {
t.Fatalf("replace lease owner error = %v, want nil", err)
}
result, err := checkGitHubSource(ctx, snapshot)
if err != nil {
t.Fatalf("checkGitHubSource(lost lease) error = %v, want stale no-op", err)
}
if result == nil || !result.Stale {
t.Errorf("checkGitHubSource(lost lease) = %+v, want stale", result)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.LeaseToken != "new-owner" || runtime.LastError != "new-owner-state" || runtime.SyncStatus != pagesSourceStatusSyncing {
t.Errorf("lost lease runtime = token:%q error:%q status:%q, want new owner state", runtime.LeaseToken, runtime.LastError, runtime.SyncStatus)
}
}
func TestGitHubCheckRateLimitUsesServerDeadlineAndSuppressesFastRetry(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-rate-limit")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{}, &githubrelease.Error{
Kind: githubrelease.ErrMetadata, StatusCode: 429, RetryAt: &retryAt,
}
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
return nil, errors.New("unexpected")
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
_, err := checkGitHubSource(ctx, snapshot)
if err == nil || err.Error() != errPagesSourceSyncFailed {
t.Fatalf("checkGitHubSource(rate limit) error = %v, want safe sync failure", err)
}
if !shouldSkipGitHubActionRetry(err) {
t.Error("shouldSkipGitHubActionRetry(rate limit) = false, want true")
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.SyncStatus != pagesSourceStatusFailed {
t.Errorf("rate limit runtime = next:%v status:%q, want deadline >= %v and failed", runtime.NextCheckAt, runtime.SyncStatus, retryAt)
}
}
func TestGitHubCheckInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-check-target")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{
Release: githubrelease.Release{ID: "1", Tag: "v1"},
Asset: githubrelease.Asset{
ID: "2", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
},
RetryAt: &retryAt,
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after invalid check target")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
_, err := checkGitHubSource(ctx, snapshot)
if err == nil || err.Error() != errPagesSourceDigestInvalid {
t.Fatalf("checkGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
}
if !isPermanentSourceSyncError(err) {
t.Error("invalid check target classification = retryable, want permanent")
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
t.Errorf(
"invalid check target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
errPagesSourceDigestInvalid, retryAt,
)
}
}
func TestGitHubSyncInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-sync-target")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
retryAt := time.Now().Add(2 * time.Hour).UTC()
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{
Release: githubrelease.Release{ID: "1", Tag: "v1"},
Asset: githubrelease.Asset{
ID: "2", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
},
RetryAt: &retryAt,
}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after invalid sync target")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
_, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
if err == nil || err.Error() != errPagesSourceDigestInvalid {
t.Fatalf("syncGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
}
if !isPermanentSourceSyncError(err) {
t.Error("invalid sync target classification = retryable, want permanent")
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
t.Errorf(
"invalid sync target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
errPagesSourceDigestInvalid, retryAt,
)
}
}
func TestGitHubCheckHandlerSkipsProviderFastRetry(t *testing.T) {
tests := []struct {
name string
status int
retryDate bool
}{
{name: "bad request", status: http.StatusBadRequest},
{name: "rate limited forbidden", status: http.StatusForbidden, retryDate: true},
{name: "too many requests", status: http.StatusTooManyRequests, retryDate: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-handler-"+strings.ReplaceAll(test.name, " ", "-"))
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
var retryAt *time.Time
if test.retryDate {
deadline := time.Now().Add(2 * time.Hour).UTC()
retryAt = &deadline
}
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{}, &githubrelease.Error{
Kind: githubrelease.ErrMetadata, StatusCode: test.status, RetryAt: retryAt,
}
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called after provider check failure")
return nil, nil
},
})
raw, err := json.Marshal(SourceActionPayload{
SourceID: source.ID, ConfigVersion: source.ConfigVersion,
Action: sourceActionCheck, Actor: "user:7",
})
if err != nil {
t.Fatalf("json.Marshal(check payload) error = %v, want nil", err)
}
result, err := (&SourceActionHandler{}).Execute(ctx, raw)
if result != nil || err == nil || !errors.Is(err, asynq.SkipRetry) {
t.Fatalf("SourceActionHandler.Execute(status %d) = result:%+v error:%v, want SkipRetry", test.status, result, err)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
t.Errorf("provider failure runtime = status:%q next:%v lease:%q", runtime.SyncStatus, runtime.NextCheckAt, runtime.LeaseToken)
}
if retryAt != nil && (runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(*retryAt)) {
t.Errorf("provider failure NextCheckAt = %v, want deadline >= %v", runtime.NextCheckAt, *retryAt)
}
})
}
}
func TestGitHubSyncActivatesWithMetadataRevisionAndPackageChecksum(t *testing.T) {
ctx := setupPagesSourceSyncTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-sync")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: "dist.zip",
})
packageBytes := testPagesZip(t, map[string]string{"index.html": "github-v1"})
packageHash := sha256.Sum256(packageBytes)
updatedAt := time.Date(2026, 7, 19, 11, 0, 0, 0, time.UTC)
retryAt := time.Now().Add(2 * time.Hour).UTC()
release := githubrelease.Release{ID: "200", Tag: "release/v2"}
asset := githubrelease.Asset{ID: "10", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt}
client := &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{Release: release, Asset: asset, RetryAt: &retryAt}, nil
},
download: func(_ context.Context, request githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
path := filepath.Join(t.TempDir(), "download")
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
t.Fatalf("os.WriteFile(download) error = %v, want nil", err)
}
return &githubrelease.DownloadResult{
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
}, nil
},
}
useFakeGitHubReleaseClient(t, client)
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
outcome, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
if err != nil {
t.Fatalf("syncGitHubSource() error = %v, want nil", err)
}
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
t.Fatalf("syncGitHubSource() = %+v, want active deployment", outcome)
}
deployment, err := model.GetPagesDeploymentByID(ctx, outcome.Deployment.ID)
if err != nil {
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", outcome.Deployment.ID, err)
}
if got, want := deployment.Checksum, hex.EncodeToString(packageHash[:]); got != want {
t.Errorf("deployment Checksum = %q, want package hash %q", got, want)
}
if deployment.SourceRevision == nil || *deployment.SourceRevision == deployment.Checksum {
t.Errorf("deployment SourceRevision = %v, want metadata revision distinct from package checksum", deployment.SourceRevision)
}
if got, want := deployment.SourceLabel, "release/v2"; got != want {
t.Errorf("deployment SourceLabel = %q, want %q", got, want)
}
if deployment.SourceType != PagesSourceTypeGitHubRelease || deployment.TriggerType != pagesSourceTriggerManualSync ||
deployment.CreatedBy != "user:7" {
t.Errorf("deployment provenance = type:%q trigger:%q actor:%q", deployment.SourceType, deployment.TriggerType, deployment.CreatedBy)
}
if strings.Contains(deployment.SourceMeta, "http") || strings.Contains(deployment.SourceMeta, "token") {
t.Errorf("deployment SourceMeta = %q, want no URL or token", deployment.SourceMeta)
}
if !strings.Contains(deployment.SourceMeta, `"tag":"release/v2"`) || strings.Contains(deployment.SourceMeta, `"label"`) {
t.Errorf("deployment SourceMeta = %q, want provider-specific tag field", deployment.SourceMeta)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
t.Errorf("sync runtime NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
}
secondSnapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
second, err := syncGitHubSource(ctx, secondSnapshot, "user:7", "", "")
if err != nil {
t.Fatalf("syncGitHubSource(idempotent) error = %v, want nil", err)
}
if second == nil || !second.Reused || second.Deployment == nil || second.Deployment.ID != outcome.Deployment.ID {
t.Errorf("syncGitHubSource(idempotent) = %+v, want reused deployment %d", second, outcome.Deployment.ID)
}
}
func TestGitHubSyncActivatesExactConfirmedReplacement(t *testing.T) {
ctx := setupPagesSourceSyncTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-replacement")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
AssetName: "dist.zip",
})
release := githubrelease.Release{ID: "300", Tag: "v3"}
asset := githubrelease.Asset{
ID: "12", Name: "dist.zip", State: "uploaded",
UpdatedAt: time.Date(2026, 7, 19, 13, 0, 0, 0, time.UTC),
}
target, err := buildGitHubSourceTarget(release, asset, nil)
if err != nil {
t.Fatalf("buildGitHubSourceTarget() error = %v, want nil", err)
}
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
"last_seen_revision": target.Revision,
"last_seen_detail": target.DetailJSON,
"last_applied_revision": strings.Repeat("a", 64),
"last_applied_detail": `{"provider":"github","release_id":"300","asset_id":"11","tag":"v3","asset_name":"dist.zip"}`,
"sync_status": pagesSourceStatusAttention,
}).Error; err != nil {
t.Fatalf("seed replacement cursor error = %v, want nil", err)
}
packageBytes := testPagesZip(t, map[string]string{"index.html": "confirmed-v3"})
packageHash := sha256.Sum256(packageBytes)
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{Release: release, Asset: asset}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
path := filepath.Join(t.TempDir(), "confirmed.zip")
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
t.Fatalf("os.WriteFile(confirmed package) error = %v, want nil", err)
}
return &githubrelease.DownloadResult{
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
}, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
outcome, err := syncGitHubSource(ctx, snapshot, "user:9", "", target.Revision)
if err != nil {
t.Fatalf("syncGitHubSource(confirmed replacement) error = %v, want nil", err)
}
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
t.Fatalf("syncGitHubSource(confirmed replacement) = %+v, want active deployment", outcome)
}
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastAppliedRevision != target.Revision {
t.Errorf("confirmed replacement runtime = status:%q applied:%q, want idle/%q", runtime.SyncStatus, runtime.LastAppliedRevision, target.Revision)
}
}
func TestSourceActionPayloadSeparatesSystemTargetAndUserConfirmation(t *testing.T) {
handler := &SourceActionHandler{}
revision := strings.Repeat("a", 64)
invalid := []SourceActionPayload{
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TargetRevision: revision},
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, ConfirmedRevision: revision},
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision, ConfirmedRevision: revision},
}
for _, payload := range invalid {
raw, _ := json.Marshal(payload)
if normalized, err := handler.ValidatePayload(raw); err == nil {
t.Errorf("ValidatePayload(%+v) = %s, nil; want error", payload, normalized)
}
}
valid := []SourceActionPayload{
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision},
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", ConfirmedRevision: revision},
}
for _, payload := range valid {
raw, _ := json.Marshal(payload)
if _, err := handler.ValidatePayload(raw); err != nil {
t.Errorf("ValidatePayload(%+v) error = %v, want nil", payload, err)
}
}
}
func TestGitHubProviderErrorsMapToSafeRetryClassification(t *testing.T) {
tests := []struct {
name string
provider error
want string
permanent bool
skipRetry bool
}{
{
name: "asset missing", provider: &githubrelease.Error{Kind: githubrelease.ErrAssetNotFound, StatusCode: 200},
want: errPagesSourceReleaseNotFound, permanent: true, skipRetry: true,
},
{
name: "digest mismatch", provider: &githubrelease.Error{Kind: githubrelease.ErrDigestMismatch, StatusCode: 200},
want: errPagesSourceDigestMismatch, permanent: true, skipRetry: true,
},
{
name: "rate limit", provider: &githubrelease.Error{
Kind: githubrelease.ErrMetadata, StatusCode: 429,
RetryAt: func() *time.Time { value := time.Now().Add(time.Hour); return &value }(),
},
want: errPagesSourceSyncFailed, permanent: false, skipRetry: true,
},
{
name: "network", provider: &githubrelease.Error{Kind: githubrelease.ErrDownload},
want: errPagesSourceSyncFailed, permanent: false, skipRetry: false,
},
{
name: "forbidden without retry", provider: &githubrelease.Error{Kind: githubrelease.ErrMetadata, StatusCode: 403},
want: errPagesSourceSyncFailed, permanent: true, skipRetry: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
domainErr := githubSourceDomainError(test.provider)
if got := domainErr.Error(); got != test.want {
t.Errorf("githubSourceDomainError() = %q, want %q", got, test.want)
}
var typedDomainError *githubSourceProviderDomainError
if !errors.As(domainErr, &typedDomainError) {
t.Fatalf("githubSourceDomainError() type = %T, want *githubSourceProviderDomainError", domainErr)
}
if got := typedDomainError.permanent; got != test.permanent {
t.Errorf("githubSourceProviderDomainError.permanent = %t, want %t", got, test.permanent)
}
if got := shouldSkipGitHubActionRetry(domainErr); got != test.skipRetry {
t.Errorf("shouldSkipGitHubActionRetry() = %t, want %t", got, test.skipRetry)
}
if strings.Contains(domainErr.Error(), "status=") || strings.Contains(domainErr.Error(), "repo=") {
t.Errorf("githubSourceDomainError() = %q, want stable Pages message", domainErr)
}
})
}
}
func TestGitHubSyncRejectsStaleConfirmationWithoutChangingActive(t *testing.T) {
ctx := setupPagesSourceSyncTest(t)
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-stale")
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old")
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeGitHubRelease,
RepositoryURL: "https://github.com/a/b",
})
asset := githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: time.Now().UTC()}
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
return githubrelease.ResolveResult{Release: githubrelease.Release{ID: "1", Tag: "v1"}, Asset: asset}, nil
},
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
t.Fatal("Download called for stale confirmation")
return nil, nil
},
})
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
_, err := syncGitHubSource(ctx, snapshot, "user:1", "", strings.Repeat("f", 64))
if err == nil || err.Error() != errPagesSourceConfirmationStale {
t.Errorf("syncGitHubSource(stale confirmation) error = %v, want %q", err, errPagesSourceConfirmationStale)
}
storedProject, loadErr := model.GetPagesProjectByID(ctx, project.ID)
if loadErr != nil {
t.Fatalf("GetPagesProjectByID() error = %v, want nil", loadErr)
}
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != oldActive.ID {
t.Errorf("ActiveDeploymentID = %v, want old active %d", storedProject.ActiveDeploymentID, oldActive.ID)
}
}
+4 -4
View File
@@ -822,8 +822,8 @@ func fenceDeploymentActivationSource(
audit.SourceIdentity = state.Source.SourceIdentity
audit.AutoDisabled = state.Source.AutoUpdateEnabled
if err := tx.Model(state.Source).Updates(map[string]any{
"config_version": state.Source.ConfigVersion + 1,
"auto_update_enabled": false,
sourceColumnConfigVersion: state.Source.ConfigVersion + 1,
sourceColumnAutoUpdateEnabled: false,
}).Error; err != nil {
return err
}
@@ -856,8 +856,8 @@ func switchActiveDeploymentTx(
return err
}
if err := tx.Model(deployment).Updates(map[string]any{
"status": model.PagesDeploymentStatusActive,
"activated_at": &now,
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
"activated_at": &now,
}).Error; err != nil {
return err
}
+18 -3
View File
@@ -45,6 +45,17 @@ func handleSourceLogicError(c *gin.Context, err error) bool {
errPagesSourceRemoteURLMode,
errPagesSourceRemoteURLInvalid,
errPagesSourceNetworkPolicy,
errPagesSourceGitHubFields,
errPagesSourceRepositoryInvalid,
errPagesSourceSelectorInvalid,
errPagesSourceAssetNameInvalid,
errPagesSourceCheckInterval,
errPagesSourceAutoNotAvailable,
errPagesSourceReleaseNotFound,
errPagesSourceDigestInvalid,
errPagesSourceDigestMismatch,
errPagesSourceConfirmationNeeded,
errPagesSourceConfirmationStale,
errPagesSourceCheckUnsupported,
errPagesSourceActionInvalid:
response.AbortBadRequest(c, err.Error())
@@ -253,7 +264,7 @@ func GetSourceHandler(c *gin.Context) {
// UpdateSourceHandler 创建或更新 Pages 项目部署源。
// @Summary 更新 Pages 部署源
// @Description Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
// @Description 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
// @Tags openflare-pages
// @Accept json
// @Produce json
@@ -275,7 +286,11 @@ func UpdateSourceHandler(c *gin.Context) {
if !decodeStrictJSON(c, &input, false) {
return
}
result, err := UpdateSource(c.Request.Context(), projectID, input)
actor, ok := currentPagesActor(c)
if !ok {
return
}
result, err := UpdateSourceAs(c.Request.Context(), projectID, input, actor)
if handleSourceLogicError(c, err) {
return
}
@@ -309,7 +324,7 @@ func DeleteSourceHandler(c *gin.Context) {
// CheckSourceHandler 请求检查 Pages 部署源。
// @Summary 检查 Pages 部署源
// @Description Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
// @Description 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
+108 -33
View File
@@ -26,7 +26,7 @@ const (
PagesSourceTypeManual = "manual"
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
PagesSourceTypeRemoteURL = "remote_url"
// PagesSourceTypeGitHubRelease is reserved for Phase 2.
// PagesSourceTypeGitHubRelease represents a public GitHub Release asset.
PagesSourceTypeGitHubRelease = "github_release"
pagesSourceStatusIdle = "idle"
@@ -37,6 +37,10 @@ const (
pagesSourceStatusAttention = "attention"
defaultRemoteAssetLabel = "pages-package"
defaultGitHubAssetName = "dist.zip"
defaultCheckInterval = 60
minimumCheckInterval = 5
maximumCheckInterval = 1440
)
// SourceUpdateInput is the discriminated source configuration payload.
@@ -72,7 +76,7 @@ type SourceView struct {
ReleaseSelector string `json:"release_selector,omitempty"`
ReleaseTag string `json:"release_tag,omitempty"`
AssetName string `json:"asset_name,omitempty"`
AutoUpdateEnabled bool `json:"auto_update_enabled,omitempty"`
AutoUpdateEnabled *bool `json:"auto_update_enabled,omitempty"`
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
SyncStatus string `json:"sync_status,omitempty"`
UpdateAvailable bool `json:"update_available,omitempty"`
@@ -99,10 +103,15 @@ type SourceUpdateResult struct {
}
type sourceDetail struct {
Provider string `json:"provider"`
Label string `json:"label"`
AssetName string `json:"asset_name,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
Provider string `json:"provider"`
DisplayName string `json:"display_name,omitempty"`
Tag string `json:"tag,omitempty"`
LegacyLabel string `json:"label,omitempty"`
AssetName string `json:"asset_name,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
AssetID string `json:"asset_id,omitempty"`
AssetUpdatedAt string `json:"asset_updated_at,omitempty"`
Digest string `json:"digest,omitempty"`
}
type remoteSourceConfig struct {
@@ -126,14 +135,43 @@ func GetSource(ctx context.Context, projectID uint) (*SourceView, error) {
return buildSourceView(source, runtime)
}
// UpdateSource creates or updates a Remote URL source and its 1:1 runtime row.
// UpdateSource creates or updates a source. Direct callers use the system actor;
// HTTP handlers should call UpdateSourceAs so the initial check is auditable.
func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput) (*SourceUpdateResult, error) {
if err := validateRemoteSourceInput(input); err != nil {
return UpdateSourceAs(ctx, projectID, input, pagesSourceCreatedBySystem)
}
// UpdateSourceAs persists source configuration and queues the first GitHub check
// after commit when the GitHub configuration was materially changed.
func UpdateSourceAs(
ctx context.Context,
projectID uint,
input SourceUpdateInput,
actor string,
) (*SourceUpdateResult, error) {
if !validPagesSourceActor(actor) {
return nil, errors.New(errPagesSourceActionInvalid)
}
if err := validateSourceUpdateInput(input); err != nil {
return nil, err
}
changed := false
var persistedSource model.PagesProjectSource
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
return updateRemoteSourceTx(tx, projectID, input)
var err error
switch strings.TrimSpace(input.SourceType) {
case PagesSourceTypeRemoteURL:
changed, err = updateRemoteSourceTx(tx, projectID, input)
case PagesSourceTypeGitHubRelease:
changed, err = updateGitHubSourceTx(tx, projectID, input)
default:
err = errors.New(errPagesSourceTypeUnsupported)
}
if err != nil || !changed || strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
return err
}
return tx.Where("project_id = ?", projectID).First(&persistedSource).Error
})
if err != nil {
return nil, err
@@ -143,24 +181,34 @@ func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput)
if err != nil {
return nil, err
}
return &SourceUpdateResult{Source: view, Warning: ""}, nil
result := &SourceUpdateResult{Source: view, Warning: ""}
if changed && strings.TrimSpace(input.SourceType) == PagesSourceTypeGitHubRelease {
receipt, dispatchErr := dispatchSourceActionSnapshot(ctx, persistedSource, sourceActionCheck, actor, "", "", "manual")
if dispatchErr != nil {
result.Warning = errPagesSourceInitialCheckWarning
markInitialCheckDispatchFailed(ctx, persistedSource.ID, persistedSource.ConfigVersion)
} else {
result.CheckTask = receipt
}
}
return result, nil
}
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) error {
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
var project model.PagesProject
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
return err
return false, err
}
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
if err != nil {
return err
return false, err
}
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
if err != nil {
return err
return false, err
}
if !hasExisting {
return createRemoteSourceTx(tx, projectID, config)
return true, createRemoteSourceTx(tx, projectID, config)
}
return updateExistingRemoteSourceTx(tx, existing, config)
}
@@ -227,33 +275,33 @@ func updateExistingRemoteSourceTx(
tx *gorm.DB,
existing *model.PagesProjectSource,
config remoteSourceConfig,
) error {
) (bool, error) {
if !remoteSourceConfigChanged(existing, config) {
return nil
return false, nil
}
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", existing.ID).
First(&runtime).Error; err != nil {
return err
return false, err
}
identityChanged := existing.SourceIdentity != config.Identity
if err := tx.Model(existing).Updates(map[string]any{
"source_type": PagesSourceTypeRemoteURL,
"remote_url": config.URL,
"remote_network_policy": config.Policy,
"github_repository": "",
"release_selector": "",
"release_tag": "",
"asset_name": "",
"auto_update_enabled": false,
"check_interval_minutes": 0,
"config_version": existing.ConfigVersion + 1,
"source_identity": config.Identity,
"source_type": PagesSourceTypeRemoteURL,
"remote_url": config.URL,
"remote_network_policy": config.Policy,
"github_repository": "",
"release_selector": "",
"release_tag": "",
"asset_name": "",
sourceColumnAutoUpdateEnabled: false,
"check_interval_minutes": 0,
sourceColumnConfigVersion: existing.ConfigVersion + 1,
"source_identity": config.Identity,
}).Error; err != nil {
return err
return false, err
}
return resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
return true, resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
}
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
@@ -328,6 +376,19 @@ func validateRemoteSourceInput(input SourceUpdateInput) error {
return nil
}
func validateSourceUpdateInput(input SourceUpdateInput) error {
switch strings.TrimSpace(input.SourceType) {
case PagesSourceTypeRemoteURL:
return validateRemoteSourceInput(input)
case PagesSourceTypeGitHubRelease:
return validateGitHubSourceInput(input)
case "":
return errors.New(errPagesSourceTypeRequired)
default:
return errors.New(errPagesSourceTypeUnsupported)
}
}
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
if input.RemoteURLSet {
return strings.TrimSpace(input.RemoteURL), nil
@@ -430,7 +491,8 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
view.ReleaseSelector = source.ReleaseSelector
view.ReleaseTag = source.ReleaseTag
view.AssetName = source.AssetName
view.AutoUpdateEnabled = source.AutoUpdateEnabled
autoUpdateEnabled := source.AutoUpdateEnabled
view.AutoUpdateEnabled = &autoUpdateEnabled
view.CheckIntervalMinutes = source.CheckIntervalMinutes
default:
return nil, errors.New(errPagesSourceTypeUnsupported)
@@ -441,7 +503,7 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
func revisionView(revision string, detailJSON string) *SourceRevisionView {
detail := sourceDetail{}
_ = unmarshalSourceDetail(detailJSON, &detail)
label := strings.TrimSpace(detail.Label)
label := sourceDetailLabel(detail)
if label == "" {
label = defaultRemoteAssetLabel
}
@@ -452,6 +514,19 @@ func revisionView(revision string, detailJSON string) *SourceRevisionView {
}
}
func sourceDetailLabel(detail sourceDetail) string {
if detail.Provider == githubSourceDetailProvider {
if label := strings.TrimSpace(detail.Tag); label != "" {
return label
}
return strings.TrimSpace(detail.LegacyLabel)
}
if label := strings.TrimSpace(detail.DisplayName); label != "" {
return label
}
return strings.TrimSpace(detail.LegacyLabel)
}
func unmarshalSourceDetail(raw string, detail *sourceDetail) error {
if detail == nil || strings.TrimSpace(raw) == "" {
return nil
@@ -24,10 +24,14 @@ const (
sourceRuntimeErrorMaxBytes = 512
sourceRevisionHexLength = 64
sourceColumnAutoUpdateEnabled = "auto_update_enabled"
sourceColumnConfigVersion = "config_version"
sourceRuntimeColumnSyncStatus = "sync_status"
sourceRuntimeColumnLastError = "last_error"
sourceRuntimeColumnLastCheckedAt = "last_checked_at"
sourceRuntimeColumnLeaseToken = "lease_token"
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
pagesDeploymentColumnStatus = "status"
)
type sourceLeaseOutcome string
@@ -50,6 +54,16 @@ type sourceExecutionSnapshot struct {
SourceIdentity string
RemoteURL string
RemoteNetworkPolicy string
GitHubRepository string
ReleaseSelector string
ReleaseTag string
AssetName string
CheckIntervalMinutes int
ETag string
LastSeenRevision string
LastSeenDetail string
LastAppliedRevision string
LastAppliedDetail string
RootDir string
EntryFile string
LeaseToken string
@@ -161,6 +175,16 @@ func loadSourceExecutionSnapshot(
SourceIdentity: source.SourceIdentity,
RemoteURL: source.RemoteURL,
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
GitHubRepository: source.GitHubRepository,
ReleaseSelector: source.ReleaseSelector,
ReleaseTag: source.ReleaseTag,
AssetName: source.AssetName,
CheckIntervalMinutes: source.CheckIntervalMinutes,
ETag: runtime.ETag,
LastSeenRevision: runtime.LastSeenRevision,
LastSeenDetail: runtime.LastSeenDetail,
LastAppliedRevision: runtime.LastAppliedRevision,
LastAppliedDetail: runtime.LastAppliedDetail,
RootDir: project.RootDir,
EntryFile: project.EntryFile,
LeaseToken: token,
+27 -11
View File
@@ -226,12 +226,14 @@ func syncRemoteSource(
ctx,
snapshot,
prepared.Candidate.Checksum,
prepared.Candidate.Checksum,
prepared.Detail,
prepared.DetailJSON,
actor,
prepared.Manifest,
ingestState.Result,
ingestState.HasIngest,
nil,
)
ingestState.Referenced = referenced
if errors.Is(err, errSourceFinalFence) {
@@ -288,7 +290,7 @@ func prepareRemoteSource(
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
return nil, err
}
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: safeRemoteSourceLabel(candidate.SafeLabel)}
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: safeRemoteSourceLabel(candidate.SafeLabel)}
detailJSON, err := json.Marshal(detail)
if err != nil {
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
@@ -336,7 +338,7 @@ func resolveSourceIngest(
prepared.Candidate.Checksum,
snapshot.ProjectID,
snapshot.SourceID,
prepared.Detail.Label,
sourceDetailLabel(prepared.Detail),
prepared.Candidate.Format,
)
if err != nil {
@@ -384,12 +386,14 @@ func commitSourceDeployment(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
manifest *deploymentManifest,
ingestResult upload.IngestResult,
hasIngest bool,
nextCheckNotBefore *time.Time,
) (*model.PagesDeployment, bool, bool, error) {
if snapshot == nil || manifest == nil {
return nil, false, false, errors.New(errPagesSourceSyncFailed)
@@ -403,7 +407,7 @@ func commitSourceDeployment(
return err
}
target, targetReused, err := resolveSourceDeploymentTx(
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
)
if err != nil {
return err
@@ -417,7 +421,7 @@ func commitSourceDeployment(
if err := refreshSourceCommitLease(state, snapshot); err != nil {
return err
}
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON); err != nil {
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON, nextCheckNotBefore); err != nil {
return err
}
committed = *target
@@ -491,6 +495,7 @@ func resolveSourceDeploymentTx(
tx *gorm.DB,
state *sourceCommitState,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
@@ -515,7 +520,7 @@ func resolveSourceDeploymentTx(
return nil, false, errSourceFinalFence
}
return createSourceDeploymentTx(
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult,
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult,
)
}
@@ -523,6 +528,7 @@ func createSourceDeploymentTx(
tx *gorm.DB,
state *sourceCommitState,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
@@ -541,7 +547,7 @@ func createSourceDeploymentTx(
target := &model.PagesDeployment{
ProjectID: state.Project.ID,
DeploymentNumber: maxNumber + 1,
Checksum: revision,
Checksum: packageChecksum,
Status: model.PagesDeploymentStatusUploaded,
UploadID: ingestResult.Upload.ID,
FileCount: manifest.FileCount,
@@ -550,7 +556,7 @@ func createSourceDeploymentTx(
SourceType: state.Source.SourceType,
SourceIdentity: &identity,
SourceRevision: &revisionValue,
SourceLabel: detail.Label,
SourceLabel: sourceDetailLabel(detail),
SourceMeta: detailJSON,
TriggerType: pagesSourceTriggerManualSync,
}
@@ -629,6 +635,7 @@ func activateSourceDeploymentTx(
target *model.PagesDeployment,
revision string,
detailJSON string,
nextCheckNotBefore *time.Time,
) error {
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", state.Project.ID).
@@ -636,8 +643,8 @@ func activateSourceDeploymentTx(
return err
}
if err := tx.Model(target).Updates(map[string]any{
"status": model.PagesDeploymentStatusActive,
"activated_at": &state.Now,
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
"activated_at": &state.Now,
}).Error; err != nil {
return err
}
@@ -645,6 +652,15 @@ func activateSourceDeploymentTx(
return err
}
finishedAt := sourceCommitNow()
var nextCheckAt any
if state.Source.SourceType == PagesSourceTypeGitHubRelease &&
state.Source.ReleaseSelector == githubReleaseSelectorLatest {
next := nextGitHubCheckAt(finishedAt, state.Source.ID, state.Source.CheckIntervalMinutes)
if nextCheckNotBefore != nil && nextCheckNotBefore.After(next) {
next = nextCheckNotBefore.UTC()
}
nextCheckAt = &next
}
result := tx.Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?",
state.Runtime.SourceID,
@@ -658,9 +674,9 @@ func activateSourceDeploymentTx(
"last_applied_detail": detailJSON,
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
sourceRuntimeColumnLastError: "",
"last_checked_at": &finishedAt,
sourceRuntimeColumnLastCheckedAt: &finishedAt,
"last_synced_at": &finishedAt,
"next_check_at": nil,
"next_check_at": nextCheckAt,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
})
@@ -388,12 +388,14 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
ctx,
snapshot,
deployment.Checksum,
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: deployment.SourceLabel},
deployment.Checksum,
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: deployment.SourceLabel},
deployment.SourceMeta,
"user:5",
&deploymentManifest{},
upload.IngestResult{},
false,
nil,
)
if !errors.Is(err, errSourceFinalFence) {
t.Fatalf("commitSourceDeployment(expired after upload lock) error = %v, want %v", err, errSourceFinalFence)
@@ -552,7 +554,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
SourceIdentity: &identity,
SourceRevision: &revision,
SourceLabel: "deleted.zip",
SourceMeta: `{"provider":"remote_url","label":"deleted.zip"}`,
SourceMeta: `{"provider":"remote_url","display_name":"deleted.zip"}`,
TriggerType: pagesSourceTriggerManualSync,
}
if err := db.DB(ctx).Create(deployment).Error; err != nil {
@@ -575,12 +577,14 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
ctx,
snapshot,
revision,
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: "deleted.zip"},
`{"provider":"remote_url","label":"deleted.zip"}`,
revision,
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: "deleted.zip"},
`{"provider":"remote_url","display_name":"deleted.zip"}`,
"user:1",
manifest,
upload.IngestResult{},
false,
nil,
)
if !errors.Is(err, errSourceFinalFence) {
t.Errorf("commitSourceDeployment(deleted upload) error = %v, want %v", err, errSourceFinalFence)
+113 -17
View File
@@ -77,7 +77,11 @@ func (h *SourceActionHandler) ValidatePayload(payload []byte) ([]byte, error) {
(input.Action != sourceActionCheck && input.Action != sourceActionSync) ||
!validPagesSourceActor(input.Actor) ||
!validOptionalSourceRevision(input.TargetRevision) ||
!validOptionalSourceRevision(input.ConfirmedRevision) {
!validOptionalSourceRevision(input.ConfirmedRevision) ||
(input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "")) ||
(input.TargetRevision != "" && input.ConfirmedRevision != "") ||
(input.TargetRevision != "" && input.Actor != pagesSourceCreatedBySystem) ||
(input.ConfirmedRevision != "" && !strings.HasPrefix(input.Actor, "user:")) {
return nil, errors.New(errPagesSourceActionInvalid)
}
return json.Marshal(input)
@@ -110,10 +114,13 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
}
if source.SourceType != PagesSourceTypeRemoteURL {
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
}
if input.TargetRevision != "" || input.ConfirmedRevision != "" {
if source.SourceType == PagesSourceTypeRemoteURL && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
return nil, task.PermanentError(errPagesSourceActionInvalid)
}
if input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
return nil, task.PermanentError(errPagesSourceActionInvalid)
}
@@ -132,10 +139,43 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
}
result, err := syncRemoteSource(ctx, snapshot, input.Actor)
if input.Action == sourceActionCheck {
return executeGitHubCheckAction(ctx, snapshot)
}
return executeSourceSyncAction(ctx, &source, snapshot, input)
}
func executeGitHubCheckAction(ctx context.Context, snapshot *sourceExecutionSnapshot) (*task.TaskResult, error) {
checkResult, checkErr := checkGitHubSource(ctx, snapshot)
if checkErr != nil {
logger.ErrorF(ctx, "[PagesSource] check failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, checkErr)
if isPermanentSourceSyncError(checkErr) || shouldSkipGitHubActionRetry(checkErr) {
return nil, task.PermanentError(checkErr.Error())
}
return nil, errors.New(errPagesSourceSyncFailed)
}
if checkResult == nil || checkResult.Stale {
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
}
return &task.TaskResult{Message: checkResult.Message, Detail: checkResult.Detail}, nil
}
func executeSourceSyncAction(
ctx context.Context,
source *model.PagesProjectSource,
snapshot *sourceExecutionSnapshot,
input SourceActionPayload,
) (*task.TaskResult, error) {
var result *sourceSyncOutcome
var err error
if source.SourceType == PagesSourceTypeGitHubRelease {
result, err = syncGitHubSource(ctx, snapshot, input.Actor, input.TargetRevision, input.ConfirmedRevision)
} else {
result, err = syncRemoteSource(ctx, snapshot, input.Actor)
}
if err != nil {
logger.ErrorF(ctx, "[PagesSource] sync failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, err)
if isPermanentSourceSyncError(err) {
if isPermanentSourceSyncError(err) || shouldSkipGitHubActionRetry(err) {
return nil, task.PermanentError(errPagesSourceSyncFailed)
}
return nil, errors.New(errPagesSourceSyncFailed)
@@ -191,13 +231,19 @@ func isPermanentSourceSyncError(err error) bool {
}
message := err.Error()
return strings.Contains(message, errPagesPackageUnsupported) ||
strings.Contains(message, errPagesPackageURLTooLarge) ||
strings.Contains(message, errPagesPackageInvalid) ||
strings.Contains(message, errPagesPackageEmpty) ||
strings.Contains(message, errPagesPackageExtractedTooLarge) ||
strings.Contains(message, errPagesPackageFileTooLarge) ||
strings.Contains(message, errPagesEntryFileMissing) ||
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
strings.Contains(message, errPagesSourceNetworkPolicy)
strings.Contains(message, errPagesSourceNetworkPolicy) ||
strings.Contains(message, errPagesSourceReleaseNotFound) ||
strings.Contains(message, errPagesSourceDigestInvalid) ||
strings.Contains(message, errPagesSourceDigestMismatch) ||
strings.Contains(message, errPagesSourceConfirmationNeeded) ||
strings.Contains(message, errPagesSourceConfirmationStale)
}
// DispatchSourceAction performs API preflight and enqueues a credential-free action.
@@ -207,8 +253,20 @@ func DispatchSourceAction(
action string,
actor string,
confirmedRevision string,
) (*SourceActionReceipt, error) {
return dispatchSourceActionByProject(ctx, projectID, action, actor, "", confirmedRevision)
}
func dispatchSourceActionByProject(
ctx context.Context,
projectID uint,
action string,
actor string,
targetRevision string,
confirmedRevision string,
) (*SourceActionReceipt, error) {
action = strings.TrimSpace(action)
targetRevision = strings.TrimSpace(targetRevision)
confirmedRevision = strings.TrimSpace(confirmedRevision)
if action != sourceActionCheck && action != sourceActionSync {
return nil, errors.New(errPagesSourceActionInvalid)
@@ -224,14 +282,8 @@ func DispatchSourceAction(
}
return nil, err
}
if source.SourceType != PagesSourceTypeRemoteURL {
return nil, errors.New(errPagesSourceTypeUnsupported)
}
if action == sourceActionCheck {
return nil, errors.New(errPagesSourceCheckUnsupported)
}
if confirmedRevision != "" {
return nil, errors.New(errPagesSourceActionInvalid)
if err := validateSourceActionPreflight(ctx, &source, action, targetRevision, confirmedRevision); err != nil {
return nil, err
}
busy, err := sourceLeaseIsBusy(ctx, source.ID)
if err != nil {
@@ -240,14 +292,58 @@ func DispatchSourceAction(
if busy {
return nil, errors.New(errPagesSourceActionBusy)
}
return dispatchSourceActionSnapshot(ctx, source, action, actor, targetRevision, confirmedRevision, "manual")
}
func validateSourceActionPreflight(
ctx context.Context,
source *model.PagesProjectSource,
action string,
targetRevision string,
confirmedRevision string,
) error {
if source == nil {
return errors.New(errPagesSourceNotFound)
}
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
return errors.New(errPagesSourceTypeUnsupported)
}
if action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
return errors.New(errPagesSourceCheckUnsupported)
}
if source.SourceType == PagesSourceTypeRemoteURL && (targetRevision != "" || confirmedRevision != "") {
return errors.New(errPagesSourceActionInvalid)
}
if action == sourceActionCheck && (targetRevision != "" || confirmedRevision != "") {
return errors.New(errPagesSourceActionInvalid)
}
if source.SourceType == PagesSourceTypeGitHubRelease && action == sourceActionSync {
if err := preflightGitHubSyncConfirmation(ctx, source.ID, confirmedRevision); err != nil {
return err
}
}
return nil
}
func dispatchSourceActionSnapshot(
ctx context.Context,
source model.PagesProjectSource,
action string,
actor string,
targetRevision string,
confirmedRevision string,
triggeredBy string,
) (*SourceActionReceipt, error) {
if task.AsynqClient == nil {
return nil, errors.New(errPagesSourceTaskDispatchFailed)
}
handler := &SourceActionHandler{}
rawPayload, err := json.Marshal(SourceActionPayload{
SourceID: source.ID,
ConfigVersion: source.ConfigVersion,
Action: action,
Actor: actor,
TargetRevision: "",
TargetRevision: targetRevision,
ConfirmedRevision: confirmedRevision,
})
if err != nil {
@@ -257,9 +353,9 @@ func DispatchSourceAction(
if err != nil {
return nil, err
}
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, "manual")
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, triggeredBy)
if err != nil {
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", projectID, source.ID, action, err)
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", source.ProjectID, source.ID, action, err)
return nil, errors.New(errPagesSourceTaskDispatchFailed)
}
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
+29 -2
View File
@@ -30,6 +30,33 @@ func setupPagesSourceTest(t *testing.T) context.Context {
return t.Context()
}
func TestRevisionViewReadsLegacySourceDetailLabel(t *testing.T) {
tests := []struct {
name string
detail string
want string
}{
{
name: "remote",
detail: `{"provider":"remote_url","label":"legacy.zip"}`,
want: "legacy.zip",
},
{
name: "github",
detail: `{"provider":"github","label":"v1.2.3","asset_name":"dist.zip"}`,
want: "v1.2.3",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
view := revisionView(strings.Repeat("a", 64), test.detail)
if view.Label != test.want {
t.Errorf("revisionView(%s).Label = %q, want %q", test.name, view.Label, test.want)
}
})
}
}
func mustCreatePagesSourceProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
t.Helper()
view, err := CreateProject(ctx, Input{
@@ -203,9 +230,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
Where("source_id = ?", source.ID).
Updates(map[string]any{
"last_seen_revision": seenRevision,
"last_seen_detail": `{"provider":"remote_url","label":"new.zip"}`,
"last_seen_detail": `{"provider":"remote_url","display_name":"new.zip"}`,
"last_applied_revision": appliedRevision,
"last_applied_detail": `{"provider":"remote_url","label":"old.zip"}`,
"last_applied_detail": `{"provider":"remote_url","display_name":"old.zip"}`,
"sync_status": pagesSourceStatusSyncing,
"lease_token": "in-flight",
"lease_expires_at": &future,
@@ -0,0 +1,725 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package githubrelease resolves and downloads public GitHub Release assets.
// It deliberately does not know about Pages projects, deployments or runtime
// state so other callers can reuse the same constrained HTTP contract.
package githubrelease
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const (
// APIVersion is the GitHub REST API contract used by this package.
APIVersion = "2026-03-10"
// SelectorLatest uses GitHub's repository latest-release endpoint.
SelectorLatest Selector = "latest"
// SelectorTag resolves one exact GitHub release tag.
SelectorTag Selector = "tag"
defaultAPIBaseURL = "https://api.github.com"
defaultUserAgent = "OpenFlare-GitHubRelease/1.0"
metadataAccept = "application/vnd.github+json"
assetAccept = "application/octet-stream"
maxMetadataBytes = 4 << 20
maxAssetErrorNames = 10
maxSafeTextBytes = 255
maxSafeAssetNameLen = 96
maxDigestBytes = 96
maxETagBytes = 512
safePartsCapacity = 6
)
var (
errInvalidRequest = errors.New("GitHub Release 请求参数无效")
errMetadata = errors.New("GitHub Release 元数据响应无效")
errAssetMissing = errors.New("GitHub Release 中未找到指定的已上传 asset")
errDownload = errors.New("GitHub Release asset 下载失败")
errTooLarge = errors.New("GitHub Release asset 超过大小限制")
errEmptyAsset = errors.New("GitHub Release asset 内容为空")
errDigest = errors.New("GitHub Release asset digest 无效或校验失败")
errCleanup = errors.New("GitHub Release 临时文件清理失败")
ownerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
repoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
hexPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
)
var (
// ErrInvalidRequest identifies caller configuration errors.
ErrInvalidRequest = errInvalidRequest
// ErrMetadata identifies malformed, unavailable or failed Release metadata requests.
ErrMetadata = errMetadata
// ErrAssetNotFound identifies an otherwise valid Release without the exact uploaded asset.
ErrAssetNotFound = errAssetMissing
// ErrDownload identifies network or HTTP failures while downloading an asset.
ErrDownload = errDownload
// ErrAssetTooLarge identifies assets that exceed the caller's hard byte limit.
ErrAssetTooLarge = errTooLarge
// ErrEmptyAsset identifies an empty downloaded asset.
ErrEmptyAsset = errEmptyAsset
// ErrDigestMismatch identifies malformed or mismatched declared SHA-256 digests.
ErrDigestMismatch = errDigest
)
// Selector identifies GitHub's own latest endpoint or one exact tag.
type Selector string
// ResolveRequest describes one public repository release asset lookup.
type ResolveRequest struct {
Repository string
Selector Selector
Tag string
AssetName string
ETag string
}
// Release contains only metadata safe and necessary for source resolution.
type Release struct {
ID string `json:"release_id"`
Tag string `json:"tag"`
Name string `json:"name,omitempty"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt time.Time `json:"published_at,omitempty"`
}
// Asset contains the immutable target metadata returned by a resolve call.
type Asset struct {
ID string `json:"asset_id"`
Name string `json:"asset_name"`
State string `json:"state"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
Digest string `json:"digest,omitempty"`
}
// ResolveResult is either a selected uploaded asset or a not-modified marker.
type ResolveResult struct {
NotModified bool `json:"not_modified"`
ETag string `json:"etag,omitempty"`
Release Release `json:"release,omitempty"`
Asset Asset `json:"asset,omitempty"`
RetryAt *time.Time `json:"retry_at,omitempty"`
}
// DownloadRequest identifies an already resolved asset. Asset IDs never come
// from an untrusted URL and the download endpoint is built locally.
type DownloadRequest struct {
Repository string
Asset Asset
MaxBytes int64
}
// DownloadResult owns a temporary file. Call Cleanup after ingestion.
type DownloadResult struct {
Path string
Size int64
SHA256 string
DeclaredDigest string
}
// Cleanup removes the temporary file and is safe to call more than once.
func (result *DownloadResult) Cleanup() error {
if result == nil || result.Path == "" {
return nil
}
name := result.Path
err := os.Remove(name)
if err == nil || errors.Is(err, os.ErrNotExist) {
result.Path = ""
return nil
}
return errCleanup
}
// Error is a safe provider error. It never retains a response body, request
// URL, redirect location or request headers.
type Error struct {
Kind error
StatusCode int
RequestID string
Repository string
Tag string
AssetName string
AvailableAssets []string
RetryAt *time.Time
}
func (providerError *Error) Error() string {
if providerError == nil {
return "GitHub Release 请求失败"
}
message := "GitHub Release 请求失败"
if providerError.Kind != nil {
message = providerError.Kind.Error()
}
parts := make([]string, 0, safePartsCapacity)
if providerError.StatusCode != 0 {
parts = append(parts, "status="+strconv.Itoa(providerError.StatusCode))
}
if providerError.RequestID != "" {
parts = append(parts, "request_id="+providerError.RequestID)
}
if providerError.Repository != "" {
parts = append(parts, "repo="+providerError.Repository)
}
if providerError.Tag != "" {
parts = append(parts, "tag="+providerError.Tag)
}
if providerError.AssetName != "" {
parts = append(parts, "asset="+providerError.AssetName)
}
if len(providerError.AvailableAssets) > 0 {
parts = append(parts, "available="+strings.Join(providerError.AvailableAssets, ","))
}
if len(parts) == 0 {
return message
}
return message + " (" + strings.Join(parts, " ") + ")"
}
func (providerError *Error) Unwrap() error {
if providerError == nil {
return nil
}
return providerError.Kind
}
// RetryAt extracts the server-directed retry deadline from an error.
func RetryAt(err error) (time.Time, bool) {
var providerError *Error
if !errors.As(err, &providerError) || providerError.RetryAt == nil {
return time.Time{}, false
}
return *providerError.RetryAt, true
}
// RetryTime is retained as a compatibility alias for early callers.
//
// Deprecated: use RetryAt.
func RetryTime(err error) (time.Time, bool) {
return RetryAt(err)
}
// IsNotFound reports both a missing Release endpoint and a Release that lacks
// the exact uploaded asset requested by the caller.
func IsNotFound(err error) bool {
if errors.Is(err, ErrAssetNotFound) {
return true
}
var providerError *Error
return errors.As(err, &providerError) && providerError.StatusCode == http.StatusNotFound
}
// IsDigestError reports malformed or mismatched declared asset digests.
func IsDigestError(err error) bool {
return errors.Is(err, ErrDigestMismatch)
}
// IsRetryable classifies provider failures without relying on localized error
// strings. Configuration, not-found, size, empty-content and digest failures
// are permanent. Network failures, 408/425/429 and 5xx responses are retryable.
func IsRetryable(err error) bool {
if err == nil || errors.Is(err, ErrInvalidRequest) || IsNotFound(err) ||
errors.Is(err, ErrAssetTooLarge) || errors.Is(err, ErrEmptyAsset) || IsDigestError(err) {
return false
}
var providerError *Error
if !errors.As(err, &providerError) {
return false
}
if providerError.StatusCode == 0 {
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
}
if providerError.StatusCode < http.StatusBadRequest {
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
}
if providerError.RetryAt != nil {
return true
}
return providerError.StatusCode == http.StatusRequestTimeout ||
providerError.StatusCode == http.StatusTooEarly ||
providerError.StatusCode == http.StatusTooManyRequests ||
providerError.StatusCode >= http.StatusInternalServerError
}
// Client accesses public GitHub Releases using a fixed, constrained transport.
type Client struct {
httpClient *http.Client
baseURL string
createTemp func(string, string) (*os.File, error)
now func() time.Time
}
// NewClient constructs a production client for api.github.com. Public
// repositories do not require or send a token.
func NewClient() *Client {
return newClient(defaultClientOptions())
}
// Resolve calls GitHub's latest or exact-tag endpoint and selects one exact,
// case-sensitive uploaded asset. It never falls back to source archives.
func (client *Client) Resolve(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
repository, tag, endpoint, err := normalizeResolveRequest(client.baseURL, request)
if err != nil {
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
}
applyMetadataHeaders(httpRequest, request.ETag)
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
if err != nil {
return ResolveResult{}, safeError(errMetadata, 0, "", repository, tag, request.AssetName, nil, nil)
}
defer func() { _ = response.Body.Close() }()
retryAt := responseRetryAt(response, client.now())
etag := safeETag(response.Header.Get("ETag"))
if response.StatusCode == http.StatusNotModified {
if etag == "" {
etag = safeETag(request.ETag)
}
return ResolveResult{NotModified: true, ETag: etag, RetryAt: retryAt}, nil
}
if response.StatusCode != http.StatusOK {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, maxMetadataBytes+1))
if readErr != nil || len(body) > maxMetadataBytes || !utf8.Valid(body) {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
var payload releasePayload
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&payload); err != nil {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
if err := ensureJSONEOF(decoder); err != nil {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
release, assets, err := convertRelease(payload)
if err != nil {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
for _, asset := range assets {
if asset.State == "uploaded" && asset.Name == request.AssetName {
return ResolveResult{
ETag: etag,
Release: release,
Asset: asset,
RetryAt: retryAt,
}, nil
}
}
available := safeAssetNames(assets)
return ResolveResult{}, safeError(
errAssetMissing,
response.StatusCode,
response.Header.Get("X-GitHub-Request-Id"),
repository,
release.Tag,
request.AssetName,
available,
retryAt,
)
}
// Download streams an asset into a package-owned temporary file while
// enforcing a hard byte limit and verifying GitHub's declared sha256 digest.
func (client *Client) Download(ctx context.Context, request DownloadRequest) (*DownloadResult, error) {
repository, err := normalizeRepository(request.Repository)
if err != nil || request.MaxBytes <= 0 || !validPositiveID(request.Asset.ID) ||
!validAssetName(request.Asset.Name) || request.Asset.Size < 0 {
return nil, safeError(errInvalidRequest, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
}
if request.Asset.Size > request.MaxBytes {
return nil, safeError(errTooLarge, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
}
endpoint := strings.TrimRight(client.baseURL, "/") + "/repos/" + repository + "/releases/assets/" + request.Asset.ID
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, safeError(errInvalidRequest, 0, "", repository, "", request.Asset.Name, nil, nil)
}
applyAssetHeaders(httpRequest)
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
if err != nil {
return nil, safeError(errDownload, 0, "", repository, "", request.Asset.Name, nil, nil)
}
defer func() { _ = response.Body.Close() }()
retryAt := responseRetryAt(response, client.now())
if response.StatusCode != http.StatusOK {
return nil, safeHTTPError(errDownload, response, repository, "", request.Asset.Name, retryAt)
}
if response.ContentLength > request.MaxBytes {
return nil, safeHTTPError(errTooLarge, response, repository, "", request.Asset.Name, retryAt)
}
result, err := client.streamAsset(response.Body, request.MaxBytes, request.Asset.Digest)
if err != nil {
return nil, safeError(err, response.StatusCode, response.Header.Get("X-GitHub-Request-Id"), repository, "", request.Asset.Name, nil, retryAt)
}
return result, nil
}
func (client *Client) streamAsset(body io.Reader, maxBytes int64, declaredDigest string) (result *DownloadResult, resultErr error) {
tempFile, err := client.createTemp("", "openflare-github-release-*")
if err != nil {
return nil, errDownload
}
tempPath := tempFile.Name()
defer func() {
closeErr := tempFile.Close()
if resultErr == nil && closeErr != nil {
resultErr = errDownload
}
if resultErr != nil {
if removeErr := os.Remove(tempPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
resultErr = errCleanup
}
}
}()
hasher := sha256.New()
readLimit := maxBytes
if readLimit < math.MaxInt64 {
readLimit++
}
size, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(body, readLimit))
if err != nil {
return nil, errDownload
}
if size > maxBytes {
return nil, errTooLarge
}
if size == 0 {
return nil, errEmptyAsset
}
checksum := hex.EncodeToString(hasher.Sum(nil))
if err := verifyDeclaredDigest(declaredDigest, checksum); err != nil {
return nil, err
}
return &DownloadResult{
Path: tempPath,
Size: size,
SHA256: checksum,
DeclaredDigest: strings.ToLower(strings.TrimSpace(declaredDigest)),
}, nil
}
type releasePayload struct {
ID json.Number `json:"id"`
Tag string `json:"tag_name"`
Name string `json:"name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt string `json:"published_at"`
Assets []assetPayload `json:"assets"`
}
type assetPayload struct {
ID json.Number `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Size int64 `json:"size"`
UpdatedAt string `json:"updated_at"`
Digest string `json:"digest"`
}
func convertRelease(payload releasePayload) (Release, []Asset, error) {
releaseID, err := positiveJSONID(payload.ID)
if err != nil {
return Release{}, nil, err
}
if !validReleaseDisplayTag(payload.Tag) {
return Release{}, nil, errMetadata
}
publishedAt, err := parseOptionalTime(payload.PublishedAt)
if err != nil {
return Release{}, nil, err
}
release := Release{
ID: releaseID,
Tag: payload.Tag,
Name: safeText(payload.Name, maxSafeTextBytes),
Draft: payload.Draft,
Prerelease: payload.Prerelease,
PublishedAt: publishedAt,
}
assets := make([]Asset, 0, len(payload.Assets))
for _, rawAsset := range payload.Assets {
assetID, assetErr := positiveJSONID(rawAsset.ID)
if assetErr != nil || rawAsset.Size < 0 {
return Release{}, nil, errMetadata
}
updatedAt, assetErr := parseOptionalTime(rawAsset.UpdatedAt)
if assetErr != nil {
return Release{}, nil, errMetadata
}
assets = append(assets, Asset{
ID: assetID,
Name: rawAsset.Name,
State: rawAsset.State,
Size: rawAsset.Size,
UpdatedAt: updatedAt,
Digest: safeText(rawAsset.Digest, maxDigestBytes),
})
}
return release, assets, nil
}
func normalizeResolveRequest(baseURL string, request ResolveRequest) (string, string, string, error) {
repository, err := normalizeRepository(request.Repository)
if err != nil || !validAssetName(request.AssetName) {
return repository, validErrorTag(request.Tag), "", errInvalidRequest
}
baseURL = strings.TrimRight(baseURL, "/")
switch request.Selector {
case SelectorLatest:
if strings.TrimSpace(request.Tag) != "" {
return repository, "", "", errInvalidRequest
}
return repository, "latest", baseURL + "/repos/" + repository + "/releases/latest", nil
case SelectorTag:
if !validTag(request.Tag) {
return repository, validErrorTag(request.Tag), "", errInvalidRequest
}
return repository, request.Tag, baseURL + "/repos/" + repository + "/releases/tags/" + url.PathEscape(request.Tag), nil
default:
return repository, validErrorTag(request.Tag), "", errInvalidRequest
}
}
func normalizeRepository(repository string) (string, error) {
repository = strings.TrimSpace(repository)
parts := strings.Split(repository, "/")
if len(parts) != 2 || !ownerPattern.MatchString(parts[0]) || !repoPattern.MatchString(parts[1]) ||
len(parts[1]) > 100 || parts[1] == "." || parts[1] == ".." {
return "", errInvalidRequest
}
return parts[0] + "/" + parts[1], nil
}
func validAssetName(assetName string) bool {
return validLogText(assetName, maxSafeTextBytes, false) && path.Base(assetName) == assetName &&
assetName != "." && assetName != ".." && !strings.ContainsAny(assetName, `/\`)
}
func validTag(tag string) bool {
if !validLogText(tag, maxSafeTextBytes, false) || strings.ContainsAny(tag, " ~^:?*[\\") ||
strings.Contains(tag, "..") || strings.Contains(tag, "@{") || strings.Contains(tag, "//") ||
strings.HasPrefix(tag, "/") || strings.HasSuffix(tag, "/") || strings.HasSuffix(tag, ".") {
return false
}
for _, component := range strings.Split(tag, "/") {
if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
return false
}
}
return true
}
func validReleaseDisplayTag(tag string) bool {
return validLogText(tag, maxSafeTextBytes, false)
}
func validLogText(value string, maxBytes int, allowEmpty bool) bool {
if (!allowEmpty && value == "") || len(value) > maxBytes || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if isLogControl(character) {
return false
}
}
return true
}
func isLogControl(character rune) bool {
if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' {
return true
}
switch character {
case '\u061c', '\u200e', '\u200f',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
'\u2066', '\u2067', '\u2068', '\u2069':
return true
default:
return false
}
}
func validErrorTag(tag string) string {
if !validTag(tag) || containsSecretDelimiter(tag) {
return ""
}
return tag
}
func validErrorAssetName(assetName string) string {
if !validAssetName(assetName) || containsSecretDelimiter(assetName) {
return ""
}
return assetName
}
func containsSecretDelimiter(value string) bool {
return strings.ContainsAny(value, "?&=#") || strings.Contains(value, "://")
}
func validPositiveID(id string) bool {
parsed, err := strconv.ParseInt(id, 10, 64)
return err == nil && parsed > 0 && strconv.FormatInt(parsed, 10) == id
}
func positiveJSONID(id json.Number) (string, error) {
parsed, err := strconv.ParseInt(id.String(), 10, 64)
if err != nil || parsed <= 0 {
return "", errMetadata
}
return strconv.FormatInt(parsed, 10), nil
}
func parseOptionalTime(value string) (time.Time, error) {
if value == "" {
return time.Time{}, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}, errMetadata
}
return parsed, nil
}
func ensureJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) {
return nil
}
return errMetadata
}
func verifyDeclaredDigest(declaredDigest string, checksum string) error {
declaredDigest = strings.TrimSpace(declaredDigest)
if declaredDigest == "" {
return nil
}
algorithm, digest, ok := strings.Cut(declaredDigest, ":")
if !ok || !strings.EqualFold(algorithm, "sha256") || !hexPattern.MatchString(digest) ||
!strings.EqualFold(digest, checksum) {
return errDigest
}
return nil
}
func safeAssetNames(assets []Asset) []string {
count := len(assets)
if count > maxAssetErrorNames {
count = maxAssetErrorNames
}
names := make([]string, 0, count)
for _, asset := range assets[:count] {
name := safeText(asset.Name, maxSafeAssetNameLen)
if containsSecretDelimiter(name) {
name = "<redacted>"
}
names = append(names, name)
}
return names
}
func safeText(value string, maxBytes int) string {
var builder strings.Builder
for _, character := range value {
if isLogControl(character) {
builder.WriteByte('?')
continue
}
builder.WriteRune(character)
if builder.Len() >= maxBytes {
break
}
}
result := builder.String()
for len(result) > maxBytes {
_, size := utf8.DecodeLastRuneInString(result)
result = result[:len(result)-size]
}
return result
}
func safeETag(value string) string {
value = strings.TrimSpace(value)
if len(value) > maxETagBytes || safeText(value, maxETagBytes) != value {
return ""
}
return value
}
func safeHTTPError(kind error, response *http.Response, repository string, tag string, assetName string, retryAt *time.Time) error {
return safeError(
kind,
response.StatusCode,
response.Header.Get("X-GitHub-Request-Id"),
repository,
tag,
assetName,
nil,
retryAt,
)
}
func safeError(
kind error,
statusCode int,
requestID string,
repository string,
tag string,
assetName string,
availableAssets []string,
retryAt *time.Time,
) error {
return &Error{
Kind: kind,
StatusCode: statusCode,
RequestID: safeErrorToken(requestID, maxSafeTextBytes),
Repository: safeErrorToken(repository, maxSafeTextBytes),
Tag: safeErrorToken(tag, maxSafeTextBytes),
AssetName: safeErrorToken(assetName, maxSafeAssetNameLen),
AvailableAssets: availableAssets,
RetryAt: retryAt,
}
}
func safeErrorToken(value string, maxBytes int) string {
if !validLogText(value, maxBytes, true) {
return ""
}
value = safeText(value, maxBytes)
if containsSecretDelimiter(value) {
return ""
}
return value
}
@@ -0,0 +1,748 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package githubrelease
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type resolverFunc func(context.Context, string, string) ([]netip.Addr, error)
func (resolve resolverFunc) LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) {
return resolve(ctx, network, host)
}
func TestResolveLatestUsesGitHubContractAndSelectsUploadedAsset(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/repos/acme/site/releases/latest" {
t.Errorf("path = %q", request.URL.Path)
}
assertHeader(t, request, "Accept", metadataAccept)
assertHeader(t, request, "User-Agent", defaultUserAgent)
assertHeader(t, request, "X-GitHub-Api-Version", APIVersion)
assertHeader(t, request, "If-None-Match", `W/"old"`)
writer.Header().Set("ETag", `W/"new"`)
writer.Header().Set("X-RateLimit-Remaining", "0")
writer.Header().Set("X-RateLimit-Reset", "1800000000")
_, _ = writer.Write([]byte(`{
"id": 9007199254740991,
"tag_name": "v1.2.3",
"name": "Stable",
"published_at": "2026-07-18T12:00:00Z",
"assets": [
{"id": 11, "name": "dist.zip", "state": "new", "size": 1},
{"id": 9007199254740990, "name": "dist.zip", "state": "uploaded", "size": 42,
"updated_at": "2026-07-18T12:10:00Z", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
]
}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
result, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site",
Selector: SelectorLatest,
AssetName: "dist.zip",
ETag: `W/"old"`,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if result.Release.ID != "9007199254740991" || result.Asset.ID != "9007199254740990" {
t.Fatalf("IDs lost precision: release=%q asset=%q", result.Release.ID, result.Asset.ID)
}
if result.ETag != `W/"new"` || result.Asset.Name != "dist.zip" || result.Asset.State != "uploaded" {
t.Fatalf("Resolve() = %+v", result)
}
if result.RetryAt == nil || result.RetryAt.Unix() != 1800000000 {
t.Fatalf("RetryAt = %v", result.RetryAt)
}
}
func TestResolveTagEscapesPathAndHandlesNotModified(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.RequestURI != "/repos/acme/site/releases/tags/release%2Fcandidate" {
t.Errorf("RequestURI = %q", request.RequestURI)
}
writer.WriteHeader(http.StatusNotModified)
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
result, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site",
Selector: SelectorTag,
Tag: "release/candidate",
AssetName: "dist.zip",
ETag: `"cached"`,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if !result.NotModified || result.ETag != `"cached"` {
t.Fatalf("Resolve() = %+v", result)
}
}
func TestResolveAssetMissingTruncatesSafeNamesAndNeverIncludesBody(t *testing.T) {
t.Parallel()
assets := make([]string, 0, 12)
for index := 0; index < 12; index++ {
assets = append(assets, fmt.Sprintf(`{"id":%d,"name":"asset-%02d.zip","state":"uploaded","size":1}`, index+1, index))
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","message":"body-token","assets":[` + strings.Join(assets, ",") + `]}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
_, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
})
if !errors.Is(err, errAssetMissing) {
t.Fatalf("Resolve() error = %v", err)
}
message := err.Error()
if !strings.Contains(message, "asset-00.zip") || !strings.Contains(message, "asset-09.zip") {
t.Fatalf("error misses safe truncated names: %s", message)
}
if strings.Contains(message, "asset-10.zip") || strings.Contains(message, "asset-11.zip") || strings.Contains(message, "body-token") {
t.Fatalf("error leaked/truncation failed: %s", message)
}
}
func TestResolveHTTPErrorParsesRateLimitWithoutBodyLeak(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.July, 19, 10, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Retry-After", "90")
writer.Header().Set("X-GitHub-Request-Id", "request-123")
writer.WriteHeader(http.StatusTooManyRequests)
_, _ = writer.Write([]byte(`{"message":"signed_url=https://secret.example/a?token=hidden"}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, func(options *clientOptions) { options.now = func() time.Time { return now } })
_, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
})
if err == nil || !strings.Contains(err.Error(), "status=429") || !strings.Contains(err.Error(), "request_id=request-123") {
t.Fatalf("Resolve() error = %v", err)
}
if strings.Contains(err.Error(), "secret.example") || strings.Contains(err.Error(), "hidden") {
t.Fatalf("error leaked body: %s", err)
}
retryAt, ok := RetryTime(err)
if !ok || !retryAt.Equal(now.Add(90*time.Second)) {
t.Fatalf("RetryTime() = %v, %v", retryAt, ok)
}
}
func TestDownloadStreamsVerifiesDigestAndCleansUp(t *testing.T) {
t.Parallel()
payload := []byte("package bytes")
digest := sha256.Sum256(payload)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/repos/acme/site/releases/assets/42" {
t.Errorf("path = %q", request.URL.Path)
}
assertHeader(t, request, "Accept", assetAccept)
assertHeader(t, request, "Accept-Encoding", "identity")
_, _ = writer.Write(payload)
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
result, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site",
Asset: Asset{
ID: "42", Name: "dist.zip", Digest: "sha256:" + hex.EncodeToString(digest[:]),
},
MaxBytes: 1024,
})
if err != nil {
t.Fatalf("Download() error = %v", err)
}
if result.Size != int64(len(payload)) || result.SHA256 != hex.EncodeToString(digest[:]) {
t.Fatalf("Download() = %+v", result)
}
if _, err := os.Stat(result.Path); err != nil {
t.Fatalf("temp file stat: %v", err)
}
if err := result.Cleanup(); err != nil {
t.Fatalf("Cleanup() error = %v", err)
}
if err := result.Cleanup(); err != nil {
t.Fatalf("second Cleanup() error = %v", err)
}
}
func TestDownloadFollows302AndStripsCrossHostSensitiveHeaders(t *testing.T) {
t.Parallel()
var targetHost string
target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
for _, header := range []string{"Authorization", "Cookie", "Proxy-Authorization", "Referer", "If-None-Match", "If-Modified-Since", "X-GitHub-Api-Version"} {
if value := request.Header.Get(header); value != "" {
t.Errorf("redirect leaked %s=%q", header, value)
}
}
_, _ = writer.Write([]byte("redirected package"))
}))
defer target.Close()
targetURL, _ := url.Parse(target.URL)
targetHost = "asset.example.test:" + targetURL.Port()
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Location", "http://"+targetHost+"/signed/package.zip?token=must-not-leak")
writer.WriteHeader(http.StatusFound)
}))
defer api.Close()
apiURL, _ := url.Parse(api.URL)
baseURL := "http://api.example.test:" + apiURL.Port()
client := newMappedTestClient(t, baseURL, nil)
result, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site", Asset: Asset{ID: "42", Name: "dist.zip"}, MaxBytes: 1024,
})
if err != nil {
t.Fatalf("Download() redirect error = %v", err)
}
if cleanupErr := result.Cleanup(); cleanupErr != nil {
t.Fatalf("Cleanup() error = %v", cleanupErr)
}
request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/repos/acme/site/releases/assets/42", nil)
applyAssetHeaders(request)
request.Header.Set("Authorization", "Bearer secret")
request.Header.Set("Cookie", "session=secret")
request.Header.Set("Proxy-Authorization", "proxy-secret")
request.Header.Set("Referer", "https://secret.example/path?token=x")
request.Header.Set("If-None-Match", `"secret-etag"`)
request.Header.Set("If-Modified-Since", time.Now().Format(http.TimeFormat))
response, err := client.httpClient.Do(request)
if err != nil {
t.Fatalf("Do() error = %v", err)
}
_ = response.Body.Close()
}
func TestRedirectSSRFAndDNSRebindingAreRejectedWithoutURLLeak(t *testing.T) {
t.Parallel()
t.Run("literal private redirect", func(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Location", "http://127.0.0.1/private?token=secret-query")
writer.WriteHeader(http.StatusFound)
}))
defer api.Close()
client := newTestClient(t, api.URL, nil)
_, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
})
if err == nil || strings.Contains(err.Error(), "secret-query") || strings.Contains(err.Error(), "127.0.0.1") {
t.Fatalf("Download() error = %v", err)
}
})
t.Run("DNS rebind between redirect and dial", func(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Location", "http://rebind.example.test/package.zip")
writer.WriteHeader(http.StatusFound)
}))
defer api.Close()
var lock sync.Mutex
calls := map[string]int{}
resolve := resolverFunc(func(_ context.Context, _ string, host string) ([]netip.Addr, error) {
lock.Lock()
defer lock.Unlock()
calls[host]++
if host == "rebind.example.test" && calls[host] > 1 {
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
}
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
})
client := newTestClient(t, api.URL, func(options *clientOptions) { options.resolver = resolve })
_, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
})
if err == nil {
t.Fatal("Download() error = nil")
}
lock.Lock()
defer lock.Unlock()
if calls["rebind.example.test"] != 2 {
t.Fatalf("rebind lookup calls = %d", calls["rebind.example.test"])
}
})
}
func TestDownloadFailureRemovesTemporaryFile(t *testing.T) {
t.Parallel()
payload := []byte("package bytes")
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write(payload)
}))
defer server.Close()
tempDir := t.TempDir()
client := newTestClient(t, server.URL, func(options *clientOptions) {
options.createTemp = func(_ string, pattern string) (*os.File, error) {
return os.CreateTemp(tempDir, pattern)
}
})
_, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site",
Asset: Asset{
ID: "42", Name: "dist.zip", Digest: "sha256:" + strings.Repeat("0", 64),
},
MaxBytes: 1024,
})
if !errors.Is(err, errDigest) {
t.Fatalf("Download() error = %v", err)
}
files, readErr := filepath.Glob(filepath.Join(tempDir, "*"))
if readErr != nil || len(files) != 0 {
t.Fatalf("temporary files after failure = %v, err=%v", files, readErr)
}
}
func TestResolveRejectsInvalidRepositoryAndAssetWithoutRequest(t *testing.T) {
t.Parallel()
client := NewClient()
for _, request := range []ResolveRequest{
{Repository: "https://github.com/acme/site", Selector: SelectorLatest, AssetName: "dist.zip"},
{Repository: "acme/site/extra", Selector: SelectorLatest, AssetName: "dist.zip"},
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "../dist.zip"},
{Repository: "acme/site", Selector: SelectorLatest, AssetName: `dir\dist.zip`},
{Repository: "acme/site", Selector: SelectorLatest, AssetName: string([]byte{'d', 'i', 's', 't', 0xff})},
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\nsecret.zip"},
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\u2028secret.zip"},
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\u202esecret.zip"},
{Repository: "acme/site", Selector: SelectorTag, AssetName: "dist.zip"},
} {
_, err := client.Resolve(context.Background(), request)
if !errors.Is(err, errInvalidRequest) {
t.Errorf("Resolve(%+v) error = %v", request, err)
}
}
}
func TestResolveAndDownloadAssetNameWithDelimiters(t *testing.T) {
t.Parallel()
assetName := "dist?channel=stable#1&x.zip"
payload := []byte("package with delimiter name")
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/repos/acme/site/releases/latest":
if request.Header.Get("If-None-Match") == "missing" {
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","assets":[]}`))
return
}
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":"release/v1","assets":[{"id":42,"name":%q,"state":"uploaded","size":%d}]}`,
assetName, len(payload))
case "/repos/acme/site/releases/assets/42":
_, _ = writer.Write(payload)
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
resolved, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorLatest, AssetName: assetName,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Asset.Name != assetName || resolved.Release.Tag != "release/v1" {
t.Fatalf("Resolve() = %+v", resolved)
}
download, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site", Asset: resolved.Asset, MaxBytes: 1024,
})
if err != nil {
t.Fatalf("Download() error = %v", err)
}
if cleanupErr := download.Cleanup(); cleanupErr != nil {
t.Fatalf("Cleanup() error = %v", cleanupErr)
}
_, err = client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorLatest, AssetName: assetName, ETag: "missing",
})
if !errors.Is(err, ErrAssetNotFound) {
t.Fatalf("missing Resolve() error = %v", err)
}
if strings.Contains(err.Error(), assetName) || strings.Contains(err.Error(), "channel=stable") {
t.Fatalf("missing error leaked delimiter-bearing name: %v", err)
}
}
func TestFixedTagGitRefRulesAndEscaping(t *testing.T) {
t.Parallel()
valid := []string{"@", "release/v1#stable&channel=prod", "foo.LOCK", "中文/发布=稳定"}
for _, tag := range valid {
if !validTag(tag) {
t.Errorf("validTag(%q) = false", tag)
}
}
invalid := []string{
"", "release v1", "release~v1", "release^v1", "release:v1", "release?v1", "release*v1",
"release[v1", `release\v1`, "release..v1", "release@{v1", "release//v1", "/release", "release/",
"release.", ".release", "release/.candidate", "release.lock", "release/v1.lock", "release\nsecret",
"release\u2028secret", "release\u202esecret", string([]byte{'v', '1', 0xff}), strings.Repeat("a", 256),
}
for _, tag := range invalid {
if validTag(tag) {
t.Errorf("validTag(%q) = true", tag)
}
}
tag := "release/v1#stable&channel=prod"
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
wantURI := "/repos/acme/site/releases/tags/" + url.PathEscape(tag)
if request.RequestURI != wantURI || request.URL.RawQuery != "" || request.URL.Fragment != "" {
t.Errorf("tag request = %q query=%q fragment=%q, want %q", request.RequestURI, request.URL.RawQuery, request.URL.Fragment, wantURI)
}
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":%q,"assets":[{"id":2,"name":"dist.zip","state":"uploaded","size":1}]}`, tag)
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
result, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorTag, Tag: tag, AssetName: "dist.zip",
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if result.Release.Tag != tag {
t.Fatalf("Release.Tag = %q", result.Release.Tag)
}
}
func TestResolveDoesNotMatchSanitizedRemoteAssetName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remote string
requested string
}{
{name: "unicode line separator", remote: "dist\u2028.zip", requested: "dist?.zip"},
{name: "overlong", remote: strings.Repeat("a", 256), requested: strings.Repeat("a", 255)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":"release/v1","assets":[{"id":2,"name":%q,"state":"uploaded","size":1}]}`, test.remote)
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
_, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorLatest, AssetName: test.requested,
})
if !errors.Is(err, ErrAssetNotFound) {
t.Fatalf("Resolve() error = %v", err)
}
})
}
}
func TestReleaseDisplayTagValidation(t *testing.T) {
t.Parallel()
for _, tag := range []string{"release/v1", "release v1", "release/v1#stable&channel=prod"} {
if !validReleaseDisplayTag(tag) {
t.Errorf("validReleaseDisplayTag(%q) = false", tag)
}
}
for _, tag := range []string{"", strings.Repeat("a", 256), "release\nsecret", "release\u2028secret", "release\u202esecret"} {
if validReleaseDisplayTag(tag) {
t.Errorf("validReleaseDisplayTag(%q) = true", tag)
}
}
}
func TestResolveRejectsInvalidUTF8Metadata(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write(append([]byte(`{"id":1,"tag_name":"v1","assets":[{"id":2,"name":"dist`),
append([]byte{0xff}, []byte(`.zip","state":"uploaded","size":1}]}`)...)...))
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
_, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist�.zip",
})
if !errors.Is(err, ErrMetadata) {
t.Fatalf("Resolve() error = %v", err)
}
}
func TestDownloadRejectsImpossibleMetadataBeforeNetwork(t *testing.T) {
t.Parallel()
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
requests.Add(1)
writer.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
tests := []struct {
name string
size int64
kind error
limit int64
}{
{name: "negative", size: -1, kind: ErrInvalidRequest, limit: 100},
{name: "declared too large", size: 101, kind: ErrAssetTooLarge, limit: 100},
}
for _, test := range tests {
_, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site",
Asset: Asset{ID: "1", Name: "dist?token=hidden#asset.zip", Size: test.size},
MaxBytes: test.limit,
})
if !errors.Is(err, test.kind) {
t.Errorf("%s Download() error = %v", test.name, err)
}
if strings.Contains(err.Error(), "token=hidden") {
t.Errorf("%s error leaked asset name: %v", test.name, err)
}
}
if got := requests.Load(); got != 0 {
t.Fatalf("HTTP requests = %d, want 0", got)
}
}
func TestLogControlCharactersNeverEnterSafeErrors(t *testing.T) {
t.Parallel()
controls := []string{"\u2028", "\u2029", "\u061c", "\u200e", "\u200f", "\u202e", "\u2066", "\u2069"}
for _, control := range controls {
secret := "before" + control + "after"
err := safeError(errInvalidRequest, 0, secret, secret, secret, secret, nil, nil)
message := err.Error()
if strings.Contains(message, secret) || strings.Contains(message, control) || strings.Contains(message, "before") {
t.Errorf("safe error retained control %U: %q", []rune(control)[0], message)
}
}
}
func TestResolveRejectsMetadataOverHardLimit(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","assets":[]}` + strings.Repeat(" ", maxMetadataBytes)))
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
_, err := client.Resolve(context.Background(), ResolveRequest{
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
})
if !errors.Is(err, ErrMetadata) {
t.Fatalf("Resolve() error = %v", err)
}
}
func TestProductionTransportRejectsSelfSignedTLS(t *testing.T) {
t.Parallel()
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte("package"))
}))
defer server.Close()
parsed, _ := url.Parse(server.URL)
dialer := &net.Dialer{Timeout: time.Second}
client := newClient(clientOptions{
baseURL: "https://api.example.test:" + parsed.Port(),
resolver: resolverFunc(func(_ context.Context, _ string, _ string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
}),
dialContext: func(ctx context.Context, network string, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, server.Listener.Addr().String())
},
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
createTemp: os.CreateTemp,
now: time.Now,
clientTimeout: 5 * time.Second,
})
_, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
})
if !errors.Is(err, ErrDownload) {
t.Fatalf("Download() error = %v", err)
}
if strings.Contains(err.Error(), "api.example.test") || strings.Contains(err.Error(), server.URL) {
t.Fatalf("TLS error leaked URL: %v", err)
}
}
func TestStableErrorClassification(t *testing.T) {
t.Parallel()
now := time.Now()
assetMissing := safeError(errAssetMissing, http.StatusOK, "", "acme/site", "v1", "dist.zip", nil, nil)
if !IsNotFound(assetMissing) || IsRetryable(assetMissing) {
t.Fatalf("asset missing classification failed: %v", assetMissing)
}
metadata404 := safeError(errMetadata, http.StatusNotFound, "", "acme/site", "v1", "dist.zip", nil, nil)
if !IsNotFound(metadata404) || IsRetryable(metadata404) {
t.Fatalf("metadata 404 classification failed: %v", metadata404)
}
digest := safeError(errDigest, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil)
if !IsDigestError(digest) || IsRetryable(digest) {
t.Fatalf("digest classification failed: %v", digest)
}
for _, retryable := range []error{
safeError(errMetadata, 0, "", "acme/site", "", "dist.zip", nil, nil),
safeError(errMetadata, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil),
safeError(errDownload, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil),
safeError(errMetadata, http.StatusInternalServerError, "", "acme/site", "", "dist.zip", nil, nil),
safeError(errMetadata, http.StatusForbidden, "", "acme/site", "", "dist.zip", nil, &now),
} {
if !IsRetryable(retryable) {
t.Errorf("IsRetryable(%v) = false", retryable)
}
}
}
func TestDownloadRedirectLimitIsSafe(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
step, _ := strconv.Atoi(request.URL.Query().Get("step"))
writer.Header().Set("Location", fmt.Sprintf("/repos/acme/site/releases/assets/1?step=%d&token=redirect-secret", step+1))
writer.WriteHeader(http.StatusFound)
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
_, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
})
if err == nil {
t.Fatal("Download() error = nil")
}
if strings.Contains(err.Error(), "redirect-secret") || strings.Contains(err.Error(), "step=") {
t.Fatalf("redirect error leaked Location: %v", err)
}
}
func TestInvalidRequestDoesNotEchoURLQueryTagOrAsset(t *testing.T) {
t.Parallel()
client := NewClient()
requests := []ResolveRequest{
{Repository: "https://github.com/acme/site?token=repo-secret", Selector: SelectorLatest, AssetName: "dist.zip"},
{Repository: "acme/site", Selector: SelectorTag, Tag: "?token=tag-secret", AssetName: "dist.zip"},
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "../dist.zip?token=asset-secret"},
}
for _, request := range requests {
_, err := client.Resolve(context.Background(), request)
if err == nil {
t.Fatalf("Resolve(%+v) error = nil", request)
}
for _, secret := range []string{"repo-secret", "tag-secret", "asset-secret", "https://github.com"} {
if strings.Contains(err.Error(), secret) {
t.Fatalf("Resolve(%+v) leaked %q: %v", request, secret, err)
}
}
}
}
func newTestClient(t *testing.T, rawBaseURL string, customize func(*clientOptions)) *Client {
t.Helper()
parsed, err := url.Parse(rawBaseURL)
if err != nil {
t.Fatal(err)
}
baseURL := "http://api.example.test:" + parsed.Port()
return newMappedTestClient(t, baseURL, customize)
}
func newMappedTestClient(t *testing.T, baseURL string, customize func(*clientOptions)) *Client {
t.Helper()
resolve := resolverFunc(func(_ context.Context, _ string, _ string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
})
dialer := &net.Dialer{Timeout: time.Second}
options := clientOptions{
baseURL: baseURL,
resolver: resolve,
allowHTTP: true,
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
dialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
_, port, splitErr := net.SplitHostPort(address)
if splitErr != nil {
return nil, splitErr
}
return dialer.DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port))
},
createTemp: os.CreateTemp,
now: time.Now,
clientTimeout: 5 * time.Second,
}
if customize != nil {
customize(&options)
}
return newClient(options)
}
func assertHeader(t *testing.T, request *http.Request, name string, expected string) {
t.Helper()
if actual := request.Header.Get(name); actual != expected {
t.Errorf("%s = %q, want %q", name, actual, expected)
}
}
func TestResponseRetryAtHTTPDate(t *testing.T) {
t.Parallel()
want := time.Date(2026, time.July, 19, 12, 30, 0, 0, time.UTC)
response := &http.Response{Header: make(http.Header)}
response.Header.Set("Retry-After", want.Format(http.TimeFormat))
if got := responseRetryAt(response, time.Time{}); got == nil || !got.Equal(want) {
t.Fatalf("responseRetryAt() = %v", got)
}
}
func TestResponseRetryAtRejectsDurationOverflow(t *testing.T) {
t.Parallel()
response := &http.Response{Header: make(http.Header)}
response.Header.Set("Retry-After", strconv.FormatInt(maxRetryAfterSeconds+1, 10))
if got := responseRetryAt(response, time.Now()); got != nil {
t.Fatalf("responseRetryAt(overflow) = %v", got)
}
}
func TestSafeETagDropsOversizedOrControlValue(t *testing.T) {
t.Parallel()
if got := safeETag(strings.Repeat("x", 513)); got != "" {
t.Fatalf("safeETag(overlong) = %q", got)
}
if got := safeETag("ok\nsecret"); got != "" {
t.Fatalf("safeETag(control) = %q", got)
}
}
func TestDownloadSizeLimit(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Length", strconv.Itoa(20))
_, _ = writer.Write([]byte(strings.Repeat("x", 20)))
}))
defer server.Close()
client := newTestClient(t, server.URL, nil)
_, err := client.Download(context.Background(), DownloadRequest{
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 10,
})
if !errors.Is(err, errTooLarge) {
t.Fatalf("Download() error = %v", err)
}
}
@@ -0,0 +1,312 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package githubrelease
import (
"context"
"crypto/tls"
"errors"
"math"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/Rain-kl/Wavelet/pkg/httppool"
)
const (
clientTimeout = 10 * time.Minute
dialTimeout = 30 * time.Second
dialKeepAlive = 30 * time.Second
responseHeaderTimeout = 30 * time.Second
maxRedirects = 5
maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
)
var (
errBlockedTarget = errors.New("GitHub Release 请求目标不是公网地址")
errResolveTarget = errors.New("GitHub Release 请求目标解析失败")
errRedirectLimit = errors.New("GitHub Release asset 重定向次数过多")
publicIPv6Prefix = netip.MustParsePrefix("2000::/3")
nonPublicPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("::ffff:0:0/96"),
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
}
)
type resolver interface {
LookupNetIP(context.Context, string, string) ([]netip.Addr, error)
}
type clientOptions struct {
baseURL string
resolver resolver
dialContext func(context.Context, string, string) (net.Conn, error)
tlsConfig *tls.Config
allowHTTP bool
createTemp func(string, string) (*os.File, error)
now func() time.Time
clientTimeout time.Duration
}
func defaultClientOptions() clientOptions {
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
return clientOptions{
baseURL: defaultAPIBaseURL,
resolver: net.DefaultResolver,
dialContext: dialer.DialContext,
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
createTemp: os.CreateTemp,
now: time.Now,
clientTimeout: clientTimeout,
}
}
func newClient(options clientOptions) *Client {
if options.baseURL == "" {
options.baseURL = defaultAPIBaseURL
}
if options.resolver == nil {
options.resolver = net.DefaultResolver
}
if options.dialContext == nil {
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
options.dialContext = dialer.DialContext
}
if options.tlsConfig == nil {
options.tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
if options.createTemp == nil {
options.createTemp = os.CreateTemp
}
if options.now == nil {
options.now = time.Now
}
if options.clientTimeout <= 0 {
options.clientTimeout = clientTimeout
}
secureDial := publicDialer(options.resolver, options.dialContext)
transport := httppool.NewTransport(httppool.TransportOptions{
Proxy: nil,
DialContext: secureDial,
TLSClientConfig: options.tlsConfig,
ResponseHeaderTimeout: responseHeaderTimeout,
TraceFilter: func(request *http.Request) bool {
return request.URL == nil || request.URL.RawQuery == ""
},
})
httpClient := &http.Client{Timeout: options.clientTimeout, Transport: transport}
httpClient.CheckRedirect = func(next *http.Request, previous []*http.Request) error {
if len(previous) > maxRedirects {
return errRedirectLimit
}
if err := validateTarget(next.Context(), next.URL, options.resolver, options.allowHTTP); err != nil {
return err
}
if len(previous) > 0 && !sameHost(previous[len(previous)-1].URL, next.URL) {
stripCrossHostHeaders(next)
}
return nil
}
return &Client{
httpClient: httpClient,
baseURL: strings.TrimRight(options.baseURL, "/"),
createTemp: options.createTemp,
now: options.now,
}
}
func applyMetadataHeaders(request *http.Request, etag string) {
request.Header.Set("Accept", metadataAccept)
request.Header.Set("User-Agent", defaultUserAgent)
request.Header.Set("X-GitHub-Api-Version", APIVersion)
if etag = safeETag(etag); etag != "" {
request.Header.Set("If-None-Match", etag)
}
}
func applyAssetHeaders(request *http.Request) {
request.Header.Set("Accept", assetAccept)
request.Header.Set("Accept-Encoding", "identity")
request.Header.Set("User-Agent", defaultUserAgent)
request.Header.Set("X-GitHub-Api-Version", APIVersion)
}
func stripCrossHostHeaders(request *http.Request) {
for _, header := range []string{
"Authorization",
"Cookie",
"Proxy-Authorization",
"Referer",
"If-None-Match",
"If-Modified-Since",
"X-GitHub-Api-Version",
} {
request.Header.Del(header)
}
}
func sameHost(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Hostname(), right.Hostname()) && effectivePort(left) == effectivePort(right)
}
func effectivePort(target *url.URL) string {
if port := target.Port(); port != "" {
return port
}
if strings.EqualFold(target.Scheme, "https") {
return "443"
}
return "80"
}
func validateTarget(ctx context.Context, target *url.URL, targetResolver resolver, allowHTTP bool) error {
if target == nil || target.User != nil || target.Fragment != "" || target.Opaque != "" || target.Hostname() == "" {
return errBlockedTarget
}
isHTTPS := strings.EqualFold(target.Scheme, "https")
isAllowedHTTP := allowHTTP && strings.EqualFold(target.Scheme, "http")
if !isHTTPS && !isAllowedHTTP {
return errBlockedTarget
}
_, err := resolvePublicIPs(ctx, targetResolver, target.Hostname())
return err
}
func publicDialer(
targetResolver resolver,
directDial func(context.Context, string, string) (net.Conn, error),
) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errResolveTarget
}
addresses, err := resolvePublicIPs(ctx, targetResolver, host)
if err != nil {
return nil, err
}
for _, resolved := range addresses {
if !ipMatchesNetwork(resolved, network) {
continue
}
connection, dialErr := directDial(ctx, network, net.JoinHostPort(resolved.String(), port))
if dialErr == nil {
return connection, nil
}
}
return nil, errDownload
}
}
func resolvePublicIPs(ctx context.Context, targetResolver resolver, host string) ([]netip.Addr, error) {
if strings.Contains(host, "%") {
return nil, errBlockedTarget
}
if literal, err := netip.ParseAddr(host); err == nil {
if !isPublicIP(literal) {
return nil, errBlockedTarget
}
return []netip.Addr{literal}, nil
}
if targetResolver == nil {
return nil, errResolveTarget
}
addresses, err := targetResolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(addresses) == 0 {
return nil, errResolveTarget
}
for _, address := range addresses {
if !isPublicIP(address) {
return nil, errBlockedTarget
}
}
return addresses, nil
}
func isPublicIP(address netip.Addr) bool {
if !address.IsValid() || address.Zone() != "" {
return false
}
address = address.Unmap()
if !address.IsGlobalUnicast() {
return false
}
if address.Is6() && !publicIPv6Prefix.Contains(address) {
return false
}
for _, prefix := range nonPublicPrefixes {
if prefix.Contains(address) {
return false
}
}
return true
}
func ipMatchesNetwork(address netip.Addr, network string) bool {
switch network {
case "tcp4":
return address.Unmap().Is4()
case "tcp6":
return address.Unmap().Is6()
default:
return true
}
}
func responseRetryAt(response *http.Response, now time.Time) *time.Time {
if response == nil {
return nil
}
if retryAfter := strings.TrimSpace(response.Header.Get("Retry-After")); retryAfter != "" {
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 && seconds <= maxRetryAfterSeconds {
retryAt := now.Add(time.Duration(seconds) * time.Second)
return &retryAt
}
if retryAt, err := http.ParseTime(retryAfter); err == nil {
retryAt = retryAt.UTC()
return &retryAt
}
}
if strings.TrimSpace(response.Header.Get("X-RateLimit-Remaining")) != "0" {
return nil
}
reset, err := strconv.ParseInt(strings.TrimSpace(response.Header.Get("X-RateLimit-Reset")), 10, 64)
if err != nil || reset <= 0 {
return nil
}
retryAt := time.Unix(reset, 0).UTC()
return &retryAt
}