Upstream Sync #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |