@@ -2,12 +2,22 @@ name: Upstream Sync
22
33# Polls for changes from router-for-me/CLIProxyAPI and merges them into this fork.
44# Plus-only provider dirs are shielded via .gitattributes (merge=ours).
5- # If the merge is clean AND build passes, push directly to main.
6- # Otherwise, open a PR for manual review.
5+ #
6+ # Outcomes:
7+ # 1. Clean merge + build + test pass -> fast-forward main directly (no PR/issue noise)
8+ # 2. Anything else (conflicts auto-resolved with theirs, build/test red, etc.)
9+ # -> open or update a SINGLE tracking issue (label: upstream-sync-blocked).
10+ # No new PR is created. The sync branch is pushed for inspection.
11+ # 3. workflow_dispatch with force_pr=true -> always open a PR for manual review.
12+ #
13+ # Invariants enforced by the supersession step:
14+ # - At most ONE open upstream-sync PR exists at any time.
15+ # - At most ONE open upstream-sync-blocked tracking issue exists at any time.
16+ # - Stale upstream-sync/* branches from prior failed runs are pruned.
717
818on :
919 schedule :
10- - cron : ' 17 * * * *' # hourly; GitHub does not emit cross-repo release events
20+ - cron : ' 17 3 * * *' # daily at 03:17 UTC; upstream releases ~1/day
1121 workflow_dispatch :
1222 inputs :
1323 force_pr :
@@ -24,6 +34,10 @@ concurrency:
2434 group : upstream-sync
2535 cancel-in-progress : false
2636
37+ env :
38+ TRACKING_ISSUE_LABEL : upstream-sync-blocked
39+ PR_LABEL : upstream-sync
40+
2741jobs :
2842 sync :
2943 runs-on : ubuntu-latest
3953 git config user.name "ccs-upstream-sync[bot]"
4054 git config user.email "ccs-upstream-sync@users.noreply.github.com"
4155
56+ - name : Supersede prior sync PRs and stale branches
57+ env :
58+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
59+ run : |
60+ set +e
61+ # Close every open upstream-sync PR and delete its head branch.
62+ # Invariant: 0 open upstream-sync PRs before we start a new run.
63+ OPEN_PRS=$(gh pr list \
64+ --repo "${{ github.repository }}" \
65+ --state open \
66+ --search 'in:title upstream-sync' \
67+ --json number,headRefName \
68+ --jq '.[] | "\(.number) \(.headRefName)"' || true)
69+ if [ -n "${OPEN_PRS}" ]; then
70+ echo "${OPEN_PRS}" | while read -r NUM BRANCH; do
71+ [ -z "${NUM}" ] && continue
72+ echo "[i] Superseding PR #${NUM} (branch ${BRANCH})"
73+ gh pr close "${NUM}" --repo "${{ github.repository }}" \
74+ --comment "Superseded by upcoming upstream-sync run." || true
75+ if [ -n "${BRANCH}" ]; then
76+ git push origin --delete "${BRANCH}" 2>/dev/null || true
77+ fi
78+ done
79+ fi
80+
81+ # Belt-and-braces: delete any orphan upstream-sync/* branches that no
82+ # longer have an associated open PR (e.g. PRs closed manually).
83+ git ls-remote --heads origin 'upstream-sync/*' \
84+ | awk '{print $2}' \
85+ | sed 's|refs/heads/||' \
86+ | while read -r BR; do
87+ [ -z "${BR}" ] && continue
88+ echo "[i] Pruning orphan branch ${BR}"
89+ git push origin --delete "${BR}" 2>/dev/null || true
90+ done
91+ set -e
92+
4293 - name : Add upstream remote
4394 run : git remote add upstream https://github.com/router-for-me/CLIProxyAPI.git
4495
@@ -65,6 +116,25 @@ jobs:
65116 git log --oneline "${UPSTREAM_MERGE_BASE}..upstream/main" | head -20
66117 fi
67118
119+ - name : Close stale tracking issue when fork is back in sync
120+ if : steps.check.outputs.has_changes == 'false'
121+ env :
122+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
123+ run : |
124+ set +e
125+ OPEN_ISSUES=$(gh issue list \
126+ --repo "${{ github.repository }}" \
127+ --state open \
128+ --label "${TRACKING_ISSUE_LABEL}" \
129+ --json number \
130+ --jq '.[].number' || true)
131+ for n in ${OPEN_ISSUES}; do
132+ echo "[i] Closing stale tracking issue #${n} (fork is in sync)."
133+ gh issue close "${n}" --repo "${{ github.repository }}" \
134+ --comment "Fork is now in sync with upstream. Auto-closing." || true
135+ done
136+ set -e
137+
68138 - name : Create sync branch
69139 if : steps.check.outputs.has_changes == 'true'
70140 id : branch
95165 echo "[!] Conflicts detected; auto-resolving with upstream side:"
96166 echo "$UNMERGED"
97167
98- # Save conflict list for PR body
168+ # Save conflict list for issue body
99169 {
100170 echo 'CONFLICT_FILES<<EOF'
101171 echo "$UNMERGED"
@@ -142,13 +212,20 @@ jobs:
142212 continue-on-error : true
143213 run : |
144214 set +e
145- go build ./...
146- BUILD_EXIT=$?
215+ BUILD_LOG=$(mktemp)
216+ go build ./... 2>&1 | tee "${BUILD_LOG}"
217+ BUILD_EXIT=${PIPESTATUS[0]}
147218 if [ $BUILD_EXIT -ne 0 ]; then
148219 echo "passed=false" >> $GITHUB_OUTPUT
149220 else
150221 echo "passed=true" >> $GITHUB_OUTPUT
151222 fi
223+ # Capture last 60 lines for issue body
224+ {
225+ echo 'BUILD_TAIL<<EOF'
226+ tail -n 60 "${BUILD_LOG}"
227+ echo 'EOF'
228+ } >> $GITHUB_ENV
152229 set -e
153230
154231 - name : Test gate
@@ -157,20 +234,27 @@ jobs:
157234 continue-on-error : true
158235 run : |
159236 set +e
160- go test ./... -count=1 -timeout 10m
161- TEST_EXIT=$?
237+ TEST_LOG=$(mktemp)
238+ go test ./... -count=1 -timeout 10m 2>&1 | tee "${TEST_LOG}"
239+ TEST_EXIT=${PIPESTATUS[0]}
162240 if [ $TEST_EXIT -ne 0 ]; then
163241 echo "passed=false" >> $GITHUB_OUTPUT
164242 else
165243 echo "passed=true" >> $GITHUB_OUTPUT
166244 fi
245+ {
246+ echo 'TEST_TAIL<<EOF'
247+ tail -n 60 "${TEST_LOG}"
248+ echo 'EOF'
249+ } >> $GITHUB_ENV
167250 set -e
168251
169252 - name : Push sync branch
170253 if : steps.check.outputs.has_changes == 'true'
171254 run : git push origin "${{ steps.branch.outputs.name }}"
172255
173- - name : Fast-forward main (clean merge + build + test pass, no conflicts, no force-pr)
256+ - name : Fast-forward main (clean merge + build + test pass)
257+ id : ffwd
174258 if : |
175259 steps.check.outputs.has_changes == 'true' &&
176260 steps.merge.outputs.conflicts == 'false' &&
@@ -183,51 +267,112 @@ jobs:
183267 git push origin main
184268 git push origin --delete "${{ steps.branch.outputs.name }}"
185269 echo "[OK] Synced ${{ steps.check.outputs.merge_base }}..${{ steps.check.outputs.upstream_head }} directly to main."
270+ echo "did_ff=true" >> $GITHUB_OUTPUT
271+
272+ - name : Close stale tracking issue (fast-forward succeeded)
273+ if : steps.ffwd.outputs.did_ff == 'true'
274+ env :
275+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
276+ run : |
277+ set +e
278+ OPEN_ISSUES=$(gh issue list \
279+ --repo "${{ github.repository }}" \
280+ --state open \
281+ --label "${TRACKING_ISSUE_LABEL}" \
282+ --json number \
283+ --jq '.[].number' || true)
284+ for n in ${OPEN_ISSUES}; do
285+ gh issue close "${n}" --repo "${{ github.repository }}" \
286+ --comment "Resolved by automated fast-forward to upstream ${{ steps.check.outputs.upstream_head }}." || true
287+ done
288+ set -e
186289
187- - name : Open PR (conflicts OR gate failure OR force_pr )
290+ - name : Open PR (force_pr only — manual escape hatch )
188291 if : |
189292 steps.check.outputs.has_changes == 'true' &&
190- (steps.merge.outputs.conflicts == 'true' ||
191- steps.build.outputs.passed == 'false' ||
192- steps.test.outputs.passed == 'false' ||
193- github.event.inputs.force_pr == 'true')
293+ github.event.inputs.force_pr == 'true'
194294 env :
195295 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
196296 run : |
197- STATUS=""
198- [ "${{ steps.merge.outputs.conflicts }}" = "true" ] && STATUS="${STATUS}- [!] Auto-resolved conflicts in: ${CONFLICT_FILES:-see diff}\n"
199- [ "${{ steps.build.outputs.passed }}" = "false" ] && STATUS="${STATUS}- [!] \`go build\` FAILED\n"
200- [ "${{ steps.test.outputs.passed }}" = "false" ] && STATUS="${STATUS}- [!] \`go test\` FAILED\n"
201- [ -z "${STATUS}" ] && STATUS="- [OK] Clean merge + gates green (force_pr requested)\n"
202297 TITLE="chore(upstream-sync): $(date -u +%Y-%m-%d) pull from router-for-me/CLIProxyAPI"
203- BODY=$(printf "## Upstream sync: \`router-for-me/CLIProxyAPI\` → \`main\`\n\nCommits being synced: \`${{ steps.check.outputs.merge_base }}..${{ steps.check.outputs.upstream_head }}\`\n\n### Gate status\n%b\n### What to do\n1. Review the diff — especially \`cmd/server/main.go\`, \`go.mod\`, \`go.sum\`.\n2. Plus-only provider dirs are protected by \`.gitattributes merge=ours\`.\n3. Re-run failing gates locally: \`go build ./... && go test ./...\`.\n4. Merge when green.\n" "${STATUS} ")
298+ 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 ")
204299 set +e
205300 PR_URL=$(gh pr create \
206301 --base main \
207302 --head "${{ steps.branch.outputs.name }}" \
208303 --title "${TITLE}" \
209304 --body "${BODY}" 2>&1)
210305 PR_EXIT=$?
211- if [ $PR_EXIT -ne 0 ]; then
212- echo "[!] gh pr create exited $PR_EXIT — retrying via REST API."
213- echo "$PR_URL"
214- PR_URL=$(gh api "repos/${{ github.repository }}/pulls" \
215- -X POST \
216- -f base=main \
217- -f head="${{ steps.branch.outputs.name }}" \
218- -f title="${TITLE}" \
219- -f body="${BODY}" \
220- --jq '.html_url' 2>&1)
221- PR_EXIT=$?
222- fi
223306 set -e
224- echo "$PR_URL"
225- if [ $PR_EXIT -ne 0 ]; then
226- echo "[!] PR creation exited $PR_EXIT — check log above."
227- exit $PR_EXIT
307+ echo "${PR_URL}"
308+ if [ $PR_EXIT -ne 0 ]; then exit $PR_EXIT; fi
309+ PR_NUM=$(echo "${PR_URL}" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+$' || true)
310+ if [ -n "${PR_NUM}" ]; then
311+ gh pr edit "${PR_NUM}" --add-label "${PR_LABEL}" 2>&1 || true
228312 fi
229- # Add label after create; tolerate label add failures (don't fail the job)
230- PR_NUM=$(echo "$PR_URL" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+$' || true)
231- if [ -n "$PR_NUM" ]; then
232- gh pr edit "$PR_NUM" --add-label "upstream-sync" 2>&1 || echo "[i] Label add skipped (label may not exist yet)."
313+
314+ - name : Update tracking issue on gate failure or unresolved conflicts
315+ if : |
316+ steps.check.outputs.has_changes == 'true' &&
317+ github.event.inputs.force_pr != 'true' &&
318+ (steps.merge.outputs.conflicts == 'true' ||
319+ steps.build.outputs.passed == 'false' ||
320+ steps.test.outputs.passed == 'false')
321+ env :
322+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
323+ run : |
324+ set +e
325+ STATUS=""
326+ [ "${{ steps.merge.outputs.conflicts }}" = "true" ] && STATUS="${STATUS}- :warning: Auto-resolved conflicts in: \`${CONFLICT_FILES:-(see diff)}\`\n"
327+ [ "${{ steps.build.outputs.passed }}" = "false" ] && STATUS="${STATUS}- :x: \`go build ./...\` FAILED\n"
328+ [ "${{ steps.test.outputs.passed }}" = "false" ] && STATUS="${STATUS}- :x: \`go test ./...\` FAILED\n"
329+
330+ BRANCH="${{ steps.branch.outputs.name }}"
331+ UPSTREAM="${{ steps.check.outputs.upstream_head }}"
332+ UPSTREAM_TAG="${{ steps.check.outputs.upstream_tag }}"
333+ RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
334+ BRANCH_URL="${{ github.server_url }}/${{ github.repository }}/tree/${BRANCH}"
335+
336+ # Ensure the label exists (idempotent).
337+ gh label create "${TRACKING_ISSUE_LABEL}" \
338+ --color "B60205" \
339+ --description "Automated upstream sync is blocked; needs maintainer attention." \
340+ --repo "${{ github.repository }}" 2>/dev/null || true
341+
342+ # Find existing open tracking issue (single-issue invariant).
343+ ISSUE_NUM=$(gh issue list \
344+ --repo "${{ github.repository }}" \
345+ --state open \
346+ --label "${TRACKING_ISSUE_LABEL}" \
347+ --json number \
348+ --jq '.[0].number' || true)
349+
350+ NOW=$(date -u '+%Y-%m-%d %H:%M UTC')
351+ 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' \
352+ "${NOW}" \
353+ "${UPSTREAM}" \
354+ "${UPSTREAM_TAG:-none}" \
355+ "${BRANCH}" "${BRANCH_URL}" \
356+ "${RUN_URL}" \
357+ "${STATUS}" \
358+ "${BUILD_TAIL:-<no build log captured>}" \
359+ "${TEST_TAIL:-<no test log captured>}")
360+
361+ if [ -n "${ISSUE_NUM}" ]; then
362+ echo "[i] Updating existing tracking issue #${ISSUE_NUM}"
363+ gh issue comment "${ISSUE_NUM}" \
364+ --repo "${{ github.repository }}" \
365+ --body "${COMMENT_BODY}" || exit 1
366+ else
367+ echo "[i] Creating new tracking issue"
368+ ISSUE_TITLE="upstream-sync blocked: needs maintainer attention"
369+ 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')
370+ ISSUE_BODY="${INTRO}${COMMENT_BODY}"
371+ gh issue create \
372+ --repo "${{ github.repository }}" \
373+ --title "${ISSUE_TITLE}" \
374+ --label "${TRACKING_ISSUE_LABEL}" \
375+ --assignee "${{ github.repository_owner }}" \
376+ --body "${ISSUE_BODY}" || exit 1
233377 fi
378+ set -e
0 commit comments