Upstream Sync #41
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). | |
| # | |
| # 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 |