Skip to content

Upstream Sync

Upstream Sync #13

Workflow file for this run

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).
# If the merge is clean AND build passes, push directly to main.
# Otherwise, open a PR for manual review.
on:
schedule:
- cron: '17 * * * *' # hourly; GitHub does not emit cross-repo release events
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
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: 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: 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 PR 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
go build ./...
BUILD_EXIT=$?
if [ $BUILD_EXIT -ne 0 ]; then
echo "passed=false" >> $GITHUB_OUTPUT
else
echo "passed=true" >> $GITHUB_OUTPUT
fi
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
go test ./... -count=1 -timeout 10m
TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then
echo "passed=false" >> $GITHUB_OUTPUT
else
echo "passed=true" >> $GITHUB_OUTPUT
fi
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, no conflicts, no force-pr)
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."
- name: Open PR (conflicts OR gate failure OR force_pr)
if: |
steps.check.outputs.has_changes == 'true' &&
(steps.merge.outputs.conflicts == 'true' ||
steps.build.outputs.passed == 'false' ||
steps.test.outputs.passed == 'false' ||
github.event.inputs.force_pr == 'true')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
STATUS=""
[ "${{ steps.merge.outputs.conflicts }}" = "true" ] && STATUS="${STATUS}- [!] Auto-resolved conflicts in: ${CONFLICT_FILES:-see diff}\n"
[ "${{ steps.build.outputs.passed }}" = "false" ] && STATUS="${STATUS}- [!] \`go build\` FAILED\n"
[ "${{ steps.test.outputs.passed }}" = "false" ] && STATUS="${STATUS}- [!] \`go test\` FAILED\n"
[ -z "${STATUS}" ] && STATUS="- [OK] Clean merge + gates green (force_pr requested)\n"
TITLE="chore(upstream-sync): $(date -u +%Y-%m-%d) pull from router-for-me/CLIProxyAPI"
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}")
set +e
PR_URL=$(gh pr create \
--base main \
--head "${{ steps.branch.outputs.name }}" \
--title "${TITLE}" \
--body "${BODY}" 2>&1)
PR_EXIT=$?
if [ $PR_EXIT -ne 0 ]; then
echo "[!] gh pr create exited $PR_EXIT — retrying via REST API."
echo "$PR_URL"
PR_URL=$(gh api "repos/${{ github.repository }}/pulls" \
-X POST \
-f base=main \
-f head="${{ steps.branch.outputs.name }}" \
-f title="${TITLE}" \
-f body="${BODY}" \
--jq '.html_url' 2>&1)
PR_EXIT=$?
fi
set -e
echo "$PR_URL"
if [ $PR_EXIT -ne 0 ]; then
echo "[!] PR creation exited $PR_EXIT — check log above."
exit $PR_EXIT
fi
# Add label after create; tolerate label add failures (don't fail the job)
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 "upstream-sync" 2>&1 || echo "[i] Label add skipped (label may not exist yet)."
fi