-
Notifications
You must be signed in to change notification settings - Fork 1
379 lines (344 loc) · 15.9 KB
/
upstream-sync.yml
File metadata and controls
379 lines (344 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
name: Upstream Sync
# Polls for changes from router-for-me/CLIProxyAPI and merges them into this fork.
# Plus-only provider dirs are shielded via .gitattributes (merge=ours).
#
# Outcomes:
# 1. Clean merge + build + test pass -> fast-forward main directly (no PR/issue noise)
# 2. Anything else (conflicts auto-resolved with theirs, build/test red, etc.)
# -> open or update a SINGLE tracking issue (label: upstream-sync-blocked).
# No new PR is created. The sync branch is pushed for inspection.
# 3. workflow_dispatch with force_pr=true -> always open a PR for manual review.
#
# Invariants enforced by the supersession step:
# - At most ONE open upstream-sync PR exists at any time.
# - At most ONE open upstream-sync-blocked tracking issue exists at any time.
# - Stale upstream-sync/* branches from prior failed runs are pruned.
on:
schedule:
- cron: '17 3 * * *' # daily at 03:17 UTC; upstream releases ~1/day
workflow_dispatch:
inputs:
force_pr:
description: 'Always open a PR even on clean merge'
required: false
default: 'false'
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: upstream-sync
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
TRACKING_ISSUE_LABEL: upstream-sync-blocked
PR_LABEL: upstream-sync
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout fork
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git
run: |
git config user.name "ccs-upstream-sync[bot]"
git config user.email "ccs-upstream-sync@users.noreply.github.com"
- name: Supersede prior sync PRs and stale branches
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
# Close every open upstream-sync PR and delete its head branch.
# Invariant: 0 open upstream-sync PRs before we start a new run.
OPEN_PRS=$(gh pr list \
--repo "${{ github.repository }}" \
--state open \
--search 'in:title upstream-sync' \
--json number,headRefName \
--jq '.[] | "\(.number) \(.headRefName)"' || true)
if [ -n "${OPEN_PRS}" ]; then
echo "${OPEN_PRS}" | while read -r NUM BRANCH; do
[ -z "${NUM}" ] && continue
echo "[i] Superseding PR #${NUM} (branch ${BRANCH})"
gh pr close "${NUM}" --repo "${{ github.repository }}" \
--comment "Superseded by upcoming upstream-sync run." || true
if [ -n "${BRANCH}" ]; then
git push origin --delete "${BRANCH}" 2>/dev/null || true
fi
done
fi
# Belt-and-braces: delete any orphan upstream-sync/* branches that no
# longer have an associated open PR (e.g. PRs closed manually).
git ls-remote --heads origin 'upstream-sync/*' \
| awk '{print $2}' \
| sed 's|refs/heads/||' \
| while read -r BR; do
[ -z "${BR}" ] && continue
echo "[i] Pruning orphan branch ${BR}"
git push origin --delete "${BR}" 2>/dev/null || true
done
set -e
- name: Add upstream remote
run: git remote add upstream https://github.com/router-for-me/CLIProxyAPI.git
- name: Fetch upstream
run: git fetch upstream main --tags
- name: Check for new upstream commits
id: check
run: |
LOCAL=$(git rev-parse HEAD)
UPSTREAM_MERGE_BASE=$(git merge-base HEAD upstream/main || echo "none")
UPSTREAM_HEAD=$(git rev-parse upstream/main)
UPSTREAM_TAG=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
echo "local=${LOCAL}" >> $GITHUB_OUTPUT
echo "upstream_head=${UPSTREAM_HEAD}" >> $GITHUB_OUTPUT
echo "upstream_tag=${UPSTREAM_TAG}" >> $GITHUB_OUTPUT
echo "merge_base=${UPSTREAM_MERGE_BASE}" >> $GITHUB_OUTPUT
if [ "${UPSTREAM_HEAD}" = "${UPSTREAM_MERGE_BASE}" ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "[i] Fork already contains all upstream commits; nothing to sync."
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "[i] New upstream commits to sync:"
git log --oneline "${UPSTREAM_MERGE_BASE}..upstream/main" | head -20
fi
- name: Close stale tracking issue when fork is back in sync
if: steps.check.outputs.has_changes == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
OPEN_ISSUES=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--label "${TRACKING_ISSUE_LABEL}" \
--json number \
--jq '.[].number' || true)
for n in ${OPEN_ISSUES}; do
echo "[i] Closing stale tracking issue #${n} (fork is in sync)."
gh issue close "${n}" --repo "${{ github.repository }}" \
--comment "Fork is now in sync with upstream. Auto-closing." || true
done
set -e
- name: Create sync branch
if: steps.check.outputs.has_changes == 'true'
id: branch
run: |
BRANCH="upstream-sync/$(date -u +%Y%m%d-%H%M)"
echo "name=${BRANCH}" >> $GITHUB_OUTPUT
git checkout -b "${BRANCH}"
- name: Merge upstream (prefer upstream, respect .gitattributes merge=ours)
if: steps.check.outputs.has_changes == 'true'
id: merge
run: |
set +e
git merge --no-edit -X theirs upstream/main
MERGE_EXIT=$?
set -e
# Collect any remaining unmerged paths (delete-modify, binary clash, etc.)
UNMERGED=$(git ls-files -u | awk '{print $4}' | sort -u || true)
if [ -z "$UNMERGED" ] && [ $MERGE_EXIT -eq 0 ]; then
echo "conflicts=false" >> $GITHUB_OUTPUT
echo "[OK] Clean merge, no conflicts."
exit 0
fi
echo "conflicts=true" >> $GITHUB_OUTPUT
echo "[!] Conflicts detected; auto-resolving with upstream side:"
echo "$UNMERGED"
# Save conflict list for issue body
{
echo 'CONFLICT_FILES<<EOF'
echo "$UNMERGED"
echo 'EOF'
} >> $GITHUB_ENV
# Auto-resolve: prefer upstream version (covers delete-modify cases).
# Files with .gitattributes merge=ours were already resolved by git.
echo "$UNMERGED" | while IFS= read -r f; do
[ -z "$f" ] && continue
# If file exists in upstream/main, take it; otherwise remove.
if git cat-file -e "upstream/main:$f" 2>/dev/null; then
git checkout --theirs -- "$f" 2>/dev/null || true
else
git rm -f --ignore-unmatch "$f" || true
fi
git add -- "$f" 2>/dev/null || true
done
# Commit the merge with resolution
git commit --no-edit -m "chore(upstream-sync): merge router-for-me/CLIProxyAPI (auto-resolved: $(echo "$UNMERGED" | tr '\n' ' '))"
echo "[OK] Conflicts auto-resolved; proceeding to build gate."
- name: Set up Go
if: steps.check.outputs.has_changes == 'true'
uses: actions/setup-go@v5
with:
go-version: '>=1.26.0'
cache: true
- name: Record synced upstream version
if: steps.check.outputs.has_changes == 'true'
run: |
{
echo "UPSTREAM_TAG=${{ steps.check.outputs.upstream_tag }}"
echo "UPSTREAM_COMMIT=${{ steps.check.outputs.upstream_head }}"
} > .ccs-fork-upstream.env
git add .ccs-fork-upstream.env
git diff --cached --quiet || git commit -m "chore(upstream-sync): record upstream version"
- name: Build gate
if: steps.check.outputs.has_changes == 'true'
id: build
continue-on-error: true
run: |
set +e
BUILD_LOG=$(mktemp)
go build ./... 2>&1 | tee "${BUILD_LOG}"
BUILD_EXIT=${PIPESTATUS[0]}
if [ $BUILD_EXIT -ne 0 ]; then
echo "passed=false" >> $GITHUB_OUTPUT
else
echo "passed=true" >> $GITHUB_OUTPUT
fi
# Capture last 60 lines for issue body
{
echo 'BUILD_TAIL<<EOF'
tail -n 60 "${BUILD_LOG}"
echo 'EOF'
} >> $GITHUB_ENV
set -e
- name: Test gate
if: steps.check.outputs.has_changes == 'true' && steps.build.outputs.passed == 'true'
id: test
continue-on-error: true
run: |
set +e
TEST_LOG=$(mktemp)
go test ./... -count=1 -timeout 10m 2>&1 | tee "${TEST_LOG}"
TEST_EXIT=${PIPESTATUS[0]}
if [ $TEST_EXIT -ne 0 ]; then
echo "passed=false" >> $GITHUB_OUTPUT
else
echo "passed=true" >> $GITHUB_OUTPUT
fi
{
echo 'TEST_TAIL<<EOF'
tail -n 60 "${TEST_LOG}"
echo 'EOF'
} >> $GITHUB_ENV
set -e
- name: Push sync branch
if: steps.check.outputs.has_changes == 'true'
run: git push origin "${{ steps.branch.outputs.name }}"
- name: Fast-forward main (clean merge + build + test pass)
id: ffwd
if: |
steps.check.outputs.has_changes == 'true' &&
steps.merge.outputs.conflicts == 'false' &&
steps.build.outputs.passed == 'true' &&
steps.test.outputs.passed == 'true' &&
github.event.inputs.force_pr != 'true'
run: |
git checkout main
git merge --ff-only "${{ steps.branch.outputs.name }}"
git push origin main
git push origin --delete "${{ steps.branch.outputs.name }}"
echo "[OK] Synced ${{ steps.check.outputs.merge_base }}..${{ steps.check.outputs.upstream_head }} directly to main."
echo "did_ff=true" >> $GITHUB_OUTPUT
- name: Close stale tracking issue (fast-forward succeeded)
if: steps.ffwd.outputs.did_ff == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
OPEN_ISSUES=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--label "${TRACKING_ISSUE_LABEL}" \
--json number \
--jq '.[].number' || true)
for n in ${OPEN_ISSUES}; do
gh issue close "${n}" --repo "${{ github.repository }}" \
--comment "Resolved by automated fast-forward to upstream ${{ steps.check.outputs.upstream_head }}." || true
done
set -e
- name: Open PR (force_pr only — manual escape hatch)
if: |
steps.check.outputs.has_changes == 'true' &&
github.event.inputs.force_pr == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TITLE="chore(upstream-sync): $(date -u +%Y-%m-%d) pull from router-for-me/CLIProxyAPI"
BODY=$(printf "## Upstream sync (force_pr): \`router-for-me/CLIProxyAPI\` -> \`main\`\n\nCommits being synced: \`${{ steps.check.outputs.merge_base }}..${{ steps.check.outputs.upstream_head }}\`\n\nManually requested via workflow_dispatch.\n")
set +e
PR_URL=$(gh pr create \
--base main \
--head "${{ steps.branch.outputs.name }}" \
--title "${TITLE}" \
--body "${BODY}" 2>&1)
PR_EXIT=$?
set -e
echo "${PR_URL}"
if [ $PR_EXIT -ne 0 ]; then exit $PR_EXIT; fi
PR_NUM=$(echo "${PR_URL}" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+$' || true)
if [ -n "${PR_NUM}" ]; then
gh pr edit "${PR_NUM}" --add-label "${PR_LABEL}" 2>&1 || true
fi
- name: Update tracking issue on gate failure or unresolved conflicts
if: |
steps.check.outputs.has_changes == 'true' &&
github.event.inputs.force_pr != 'true' &&
(steps.merge.outputs.conflicts == 'true' ||
steps.build.outputs.passed == 'false' ||
steps.test.outputs.passed == 'false')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
STATUS=""
[ "${{ steps.merge.outputs.conflicts }}" = "true" ] && STATUS="${STATUS}- :warning: Auto-resolved conflicts in: \`${CONFLICT_FILES:-(see diff)}\`\n"
[ "${{ steps.build.outputs.passed }}" = "false" ] && STATUS="${STATUS}- :x: \`go build ./...\` FAILED\n"
[ "${{ steps.test.outputs.passed }}" = "false" ] && STATUS="${STATUS}- :x: \`go test ./...\` FAILED\n"
BRANCH="${{ steps.branch.outputs.name }}"
UPSTREAM="${{ steps.check.outputs.upstream_head }}"
UPSTREAM_TAG="${{ steps.check.outputs.upstream_tag }}"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
BRANCH_URL="${{ github.server_url }}/${{ github.repository }}/tree/${BRANCH}"
# Ensure the label exists (idempotent).
gh label create "${TRACKING_ISSUE_LABEL}" \
--color "B60205" \
--description "Automated upstream sync is blocked; needs maintainer attention." \
--repo "${{ github.repository }}" 2>/dev/null || true
# Find existing open tracking issue (single-issue invariant).
ISSUE_NUM=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--label "${TRACKING_ISSUE_LABEL}" \
--json number \
--jq '.[0].number' || true)
NOW=$(date -u '+%Y-%m-%d %H:%M UTC')
COMMENT_BODY=$(printf '**New blocked sync attempt** (%s)\n\n- Upstream HEAD: `%s`\n- Upstream tag: `%s`\n- Sync branch: [%s](%s)\n- Workflow run: %s\n\n### Gate status\n%b\n<details><summary>Build tail (last 60 lines)</summary>\n\n```\n%s\n```\n\n</details>\n\n<details><summary>Test tail (last 60 lines)</summary>\n\n```\n%s\n```\n\n</details>\n' \
"${NOW}" \
"${UPSTREAM}" \
"${UPSTREAM_TAG:-none}" \
"${BRANCH}" "${BRANCH_URL}" \
"${RUN_URL}" \
"${STATUS}" \
"${BUILD_TAIL:-<no build log captured>}" \
"${TEST_TAIL:-<no test log captured>}")
if [ -n "${ISSUE_NUM}" ]; then
echo "[i] Updating existing tracking issue #${ISSUE_NUM}"
gh issue comment "${ISSUE_NUM}" \
--repo "${{ github.repository }}" \
--body "${COMMENT_BODY}" || exit 1
else
echo "[i] Creating new tracking issue"
ISSUE_TITLE="upstream-sync blocked: needs maintainer attention"
INTRO=$(printf 'The automated upstream sync from `router-for-me/CLIProxyAPI` cannot fast-forward `main` cleanly. This issue tracks the blocked state. **It will be auto-closed** once a sync run reaches main without intervention.\n\nEach subsequent failed run will append a comment with the latest failing commit and gate output. **No new PRs or issues are spawned** — this is the single source of truth.\n\n### What to do\n1. Pull the latest sync branch listed in the most recent comment.\n2. Reproduce locally: `go build ./... && go test ./...`.\n3. Patch Plus-only files (`.gitattributes merge=ours` paths) to track upstream API changes.\n4. Push the fix to `main`. The next scheduled run will close this issue.\n\nIf the fork has truly diverged beyond simple repair, consider reducing the `merge=ours` surface area in `.gitattributes`.\n\n---\n')
ISSUE_BODY="${INTRO}${COMMENT_BODY}"
gh issue create \
--repo "${{ github.repository }}" \
--title "${ISSUE_TITLE}" \
--label "${TRACKING_ISSUE_LABEL}" \
--assignee "${{ github.repository_owner }}" \
--body "${ISSUE_BODY}" || exit 1
fi
set -e