Skip to content

Upstream Sync

Upstream Sync #41

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).
#
# 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