Skip to content

Commit f8e3834

Browse files
committed
Harden GitHub sync dispatch errors
1 parent e500a15 commit f8e3834

7 files changed

Lines changed: 144 additions & 8 deletions

File tree

apps/web/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,12 @@ NIKE_REFRESH_TOKEN=your_nike_refresh_token
3030
# Mapbox (for maps)
3131
NEXT_PUBLIC_MAPBOX_TOKEN=your_mapbox_token
3232

33+
# GitHub Actions manual sync dispatcher (server-only)
34+
# Fine-grained token: repository oiahoon/running2.0, Actions permission set to read/write.
35+
GITHUB_ACTIONS_TRIGGER_TOKEN=your_fine_grained_github_token
36+
GITHUB_SYNC_REPOSITORY=oiahoon/running2.0
37+
GITHUB_SYNC_WORKFLOW_ID=sync-data.yml
38+
GITHUB_SYNC_REF=master
39+
3340
# Development
3441
NODE_ENV=development

apps/web/src/app/api/sync/route.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { getDatabase } from '@/lib/database/connection'
3+
import type { SyncErrorCode } from '@/lib/syncErrors'
34

45
type SourceSetting = {
56
source: string
@@ -15,6 +16,17 @@ type SourceLog = {
1516
completed_at: string | null
1617
}
1718

19+
class WorkflowDispatchError extends Error {
20+
constructor(
21+
message: string,
22+
readonly code: SyncErrorCode,
23+
readonly httpStatus: number
24+
) {
25+
super(message)
26+
this.name = 'WorkflowDispatchError'
27+
}
28+
}
29+
1830
function normalizeSourceId(sourceId: string): string {
1931
if (sourceId === 'strava') return sourceId
2032
const prefix = sourceId.split('_')[0]
@@ -32,7 +44,11 @@ function toWorkflowBoolean(value: unknown) {
3244
async function triggerSyncWorkflow(body: Record<string, unknown>) {
3345
const token = process.env.GITHUB_ACTIONS_TRIGGER_TOKEN
3446
if (!token) {
35-
throw new Error('Manual sync trigger is not configured. Set GITHUB_ACTIONS_TRIGGER_TOKEN in Vercel.')
47+
throw new WorkflowDispatchError(
48+
'Manual sync is not configured.',
49+
'sync_not_configured',
50+
503
51+
)
3652
}
3753

3854
const repository = getGitHubRepo()
@@ -58,8 +74,55 @@ async function triggerSyncWorkflow(body: Record<string, unknown>) {
5874
})
5975

6076
if (!response.ok) {
61-
const detail = await response.text().catch(() => '')
62-
throw new Error(`GitHub workflow dispatch failed (${response.status}): ${detail || response.statusText}`)
77+
const requestId = response.headers.get('x-github-request-id')
78+
console.error('GitHub workflow dispatch rejected', {
79+
status: response.status,
80+
requestId,
81+
repository,
82+
workflowId,
83+
ref,
84+
})
85+
86+
if (response.status === 401) {
87+
throw new WorkflowDispatchError(
88+
'Manual sync authorization is invalid.',
89+
'github_auth_invalid',
90+
503
91+
)
92+
}
93+
if (response.status === 403) {
94+
throw new WorkflowDispatchError(
95+
'Manual sync authorization cannot dispatch this workflow.',
96+
'github_permission_denied',
97+
503
98+
)
99+
}
100+
if (response.status === 404) {
101+
throw new WorkflowDispatchError(
102+
'The configured sync workflow was not found.',
103+
'workflow_not_found',
104+
503
105+
)
106+
}
107+
if (response.status === 422) {
108+
throw new WorkflowDispatchError(
109+
'The configured sync workflow, ref, or inputs are invalid.',
110+
'workflow_config_invalid',
111+
502
112+
)
113+
}
114+
if (response.status >= 500) {
115+
throw new WorkflowDispatchError(
116+
'GitHub is temporarily unavailable.',
117+
'github_unavailable',
118+
502
119+
)
120+
}
121+
throw new WorkflowDispatchError(
122+
'The sync workflow could not be queued.',
123+
'dispatch_failed',
124+
502
125+
)
63126
}
64127

65128
return {
@@ -168,8 +231,14 @@ export async function POST(request: NextRequest) {
168231
)
169232
} catch (error) {
170233
console.error('Failed to sync data sources:', error)
234+
if (error instanceof WorkflowDispatchError) {
235+
return NextResponse.json(
236+
{ error: error.message, code: error.code },
237+
{ status: error.httpStatus }
238+
)
239+
}
171240
return NextResponse.json(
172-
{ error: error instanceof Error ? error.message : 'Failed to sync data sources' },
241+
{ error: 'Failed to sync data sources', code: 'dispatch_failed' satisfies SyncErrorCode },
173242
{ status: 500 }
174243
)
175244
}

apps/web/src/app/data-sources/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useEffect, useMemo, useState } from 'react'
44
import { useI18n } from '@/lib/i18n'
5+
import { readSyncError } from '@/lib/syncErrors'
56

67
interface DataSourceType {
78
id: string
@@ -69,8 +70,7 @@ export default function DataSourcesPage() {
6970
body: JSON.stringify({ sources: ['strava'] }),
7071
})
7172
if (!response.ok) {
72-
const data = await response.json().catch(() => ({}))
73-
throw new Error(data?.error || t('sync.manualFailed'))
73+
throw new Error(await readSyncError(response, t))
7474
}
7575
const data = await response.json().catch(() => ({}))
7676
setMessage(data?.message || t('sources.syncQueued'))

apps/web/src/app/sync/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useMemo, useState } from 'react'
44
import { ArrowPathIcon } from '@heroicons/react/24/outline'
55
import { useI18n } from '@/lib/i18n'
6+
import { readSyncError } from '@/lib/syncErrors'
67

78
interface SyncRecord {
89
id: string
@@ -90,8 +91,7 @@ export default function SyncPage() {
9091
body: JSON.stringify({ sources: ['strava'] }),
9192
})
9293
if (!response.ok) {
93-
const data = await response.json().catch(() => ({}))
94-
throw new Error(data?.error || t('sync.manualFailed'))
94+
throw new Error(await readSyncError(response, t))
9595
}
9696
const data = await response.json().catch(() => ({}))
9797
setNotice(data?.message ? t('sync.refreshAfterDeploy', { message: data.message }) : t('sync.queued'))

apps/web/src/lib/i18n.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ const dictionaries: Record<Locale, Record<string, string>> = {
341341
'sync.status.failed': 'Failed',
342342
'sync.status.running': 'Running',
343343
'sync.status.error': 'Error',
344+
'sync.error.notConfigured': 'Manual sync is not configured. Ask an administrator to add the GitHub credential.',
345+
'sync.error.githubAuthInvalid': 'Manual sync authorization has expired. Ask an administrator to refresh the GitHub credential.',
346+
'sync.error.githubPermissionDenied': 'The GitHub credential cannot run this workflow. It needs Actions write access for this repository.',
347+
'sync.error.workflowNotFound': 'The configured GitHub sync workflow could not be found.',
348+
'sync.error.workflowConfigInvalid': 'The GitHub sync workflow, branch, or inputs are configured incorrectly.',
349+
'sync.error.githubUnavailable': 'GitHub is temporarily unavailable. Try again shortly.',
350+
'sync.error.dispatchFailed': 'The sync workflow could not be queued. Try again.',
344351
'sync.workflow': 'Workflow',
345352
'sync.workflow1': '1. Refresh latest source status',
346353
'sync.workflow2': '2. Queue GitHub Actions sync',
@@ -670,6 +677,13 @@ const dictionaries: Record<Locale, Record<string, string>> = {
670677
'sync.status.failed': '失败',
671678
'sync.status.running': '运行中',
672679
'sync.status.error': '错误',
680+
'sync.error.notConfigured': '手动同步尚未配置,请联系管理员添加 GitHub 凭据。',
681+
'sync.error.githubAuthInvalid': '手动同步授权已失效,请联系管理员刷新 GitHub 凭据。',
682+
'sync.error.githubPermissionDenied': 'GitHub 凭据无法运行此工作流,需要此仓库的 Actions 写入权限。',
683+
'sync.error.workflowNotFound': '未找到已配置的 GitHub 同步工作流。',
684+
'sync.error.workflowConfigInvalid': 'GitHub 同步工作流、分支或输入配置有误。',
685+
'sync.error.githubUnavailable': 'GitHub 暂时不可用,请稍后重试。',
686+
'sync.error.dispatchFailed': '无法将同步工作流加入队列,请重试。',
673687
'sync.workflow': '工作流',
674688
'sync.workflow1': '1. 刷新最新来源状态',
675689
'sync.workflow2': '2. 排队执行 GitHub Actions 同步',
@@ -999,6 +1013,13 @@ const dictionaries: Record<Locale, Record<string, string>> = {
9991013
'sync.status.failed': '失敗',
10001014
'sync.status.running': '実行中',
10011015
'sync.status.error': 'エラー',
1016+
'sync.error.notConfigured': '手動同期が設定されていません。管理者に GitHub 認証情報の追加を依頼してください。',
1017+
'sync.error.githubAuthInvalid': '手動同期の認証期限が切れています。管理者に GitHub 認証情報の更新を依頼してください。',
1018+
'sync.error.githubPermissionDenied': 'GitHub 認証情報でこのワークフローを実行できません。このリポジトリの Actions 書き込み権限が必要です。',
1019+
'sync.error.workflowNotFound': '設定された GitHub 同期ワークフローが見つかりません。',
1020+
'sync.error.workflowConfigInvalid': 'GitHub 同期ワークフロー、ブランチ、または入力の設定が正しくありません。',
1021+
'sync.error.githubUnavailable': 'GitHub は一時的に利用できません。しばらくしてから再試行してください。',
1022+
'sync.error.dispatchFailed': '同期ワークフローをキューに追加できませんでした。再試行してください。',
10021023
'sync.workflow': 'ワークフロー',
10031024
'sync.workflow1': '1. 最新ソース状態を更新',
10041025
'sync.workflow2': '2. GitHub Actions 同期をキュー',

apps/web/src/lib/syncErrors.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export type SyncErrorCode =
2+
| 'sync_not_configured'
3+
| 'github_auth_invalid'
4+
| 'github_permission_denied'
5+
| 'workflow_not_found'
6+
| 'workflow_config_invalid'
7+
| 'github_unavailable'
8+
| 'dispatch_failed'
9+
10+
const errorMessageKeys: Record<SyncErrorCode, string> = {
11+
sync_not_configured: 'sync.error.notConfigured',
12+
github_auth_invalid: 'sync.error.githubAuthInvalid',
13+
github_permission_denied: 'sync.error.githubPermissionDenied',
14+
workflow_not_found: 'sync.error.workflowNotFound',
15+
workflow_config_invalid: 'sync.error.workflowConfigInvalid',
16+
github_unavailable: 'sync.error.githubUnavailable',
17+
dispatch_failed: 'sync.error.dispatchFailed',
18+
}
19+
20+
function isSyncErrorCode(value: unknown): value is SyncErrorCode {
21+
return typeof value === 'string' && Object.hasOwn(errorMessageKeys, value)
22+
}
23+
24+
export async function readSyncError(
25+
response: Response,
26+
translate: (key: string) => string
27+
) {
28+
const payload = await response.json().catch(() => ({})) as {
29+
code?: unknown
30+
error?: string
31+
}
32+
const messageKey = isSyncErrorCode(payload.code) ? errorMessageKeys[payload.code] : undefined
33+
return messageKey ? translate(messageKey) : payload.error || translate('sync.manualFailed')
34+
}

docs/agentic/sync-ops.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ Manual sync was verified end-to-end on 2026-04-30 after refreshing the GitHub di
2020
- The workflow committed data update `9c086bfbdb153cac2b2f49aad0d5a7408a1b7a86`.
2121
- Production `/api/sync/history` reflected the new sync log at `2026-04-30T12:21:33.379Z`.
2222

23+
Manual sync was reverified on 2026-07-14 after rotating the expired GitHub dispatch token:
24+
- `POST https://run2.miaowu.org/api/sync` returned `202` with structured `queued` state.
25+
- GitHub Actions run `29344918816` completed successfully from `workflow_dispatch`.
26+
- The dispatcher now maps GitHub authorization and workflow configuration failures to stable error codes, without returning raw upstream response bodies to the browser.
27+
2328
## Runtime Sync
2429

2530
- Direct Strava executor: `apps/web/src/app/api/sync/strava/route.ts`

0 commit comments

Comments
 (0)