Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .github/scripts/test_update_fork.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import contextlib
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch

spec = importlib.util.spec_from_file_location("update_fork", Path(__file__).with_name("update-fork.py"))
updater = importlib.util.module_from_spec(spec)
spec.loader.exec_module(updater)


class UpdateForkTest(unittest.TestCase):
def setUp(self):
environment = patch.dict(os.environ, {"GITHUB_ACTIONS": "false", "GITHUB_STEP_SUMMARY": ""})
environment.start()
self.addCleanup(environment.stop)
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.original_cwd = Path.cwd()
self.addCleanup(os.chdir, self.original_cwd)
self.root = Path(self.directory.name)
self.upstream = self.root / "upstream.git"
self.origin = self.root / "origin.git"
self.checkout = self.root / "checkout"
for path in (self.upstream, self.origin):
self.git("init", "--bare", str(path), cwd=self.root)
self.git("init", "--initial-branch=main", str(self.checkout), cwd=self.root)
os.chdir(self.checkout)
self.git("config", "user.name", "Maintenance test")
self.git("config", "user.email", "maintenance@example.test")
self.git("config", "core.autocrlf", "false")
self.git("config", "commit.gpgsign", "false")
self.write("routing.txt", "official base\n")
self.git("add", ".")
self.git("commit", "-m", "official base")
self.base = self.git("rev-parse", "HEAD").stdout.strip()
self.git("remote", "add", "origin", str(self.origin))
self.git("push", "origin", "main")
self.git("push", str(self.upstream), "main")
self.git("switch", "-c", "custom")
self.write("console.txt", "custom console\n")
self.git("add", ".")
self.git("commit", "-m", "custom console")
self.git("push", "origin", "custom")
self.release = self.base
self.tag = "v8.0.28"
self.prs = []
self.dispatches = []
self.real_run = updater.run

def git(self, *args, cwd=None):
result = subprocess.run(["git", *args], cwd=cwd, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
return result

def write(self, name, text):
(self.checkout / name).write_text(text, encoding="utf-8")

def upstream_release(self, conflict=False):
self.git("switch", "main")
self.write("routing.txt" if conflict else "official.txt", "new official behavior\n")
self.git("add", ".")
self.git("commit", "-m", "official release")
self.git("tag", self.tag)
self.release = self.git("rev-parse", "HEAD").stdout.strip()
self.git("push", str(self.upstream), "main", self.tag)
self.git("switch", "custom")
if conflict:
self.write("routing.txt", "custom routing behavior\n")
self.git("add", ".")
self.git("commit", "-m", "custom routing")
self.git("push", "origin", "custom")

def fake_run(self, *args, check=True):
if args[0] == "gh":
if args[1:3] == ("repo", "view"):
data = {"nameWithOwner": updater.FORK}
elif args[1] == "api":
data = {"tag_name": self.tag, "draft": False, "prerelease": False,
"html_url": f"https://example.test/releases/{self.tag}"}
elif args[1:3] == ("pr", "list"):
data = self.prs
elif args[1:3] == ("pr", "create"):
self.created_args = args
self.created_body = Path(args[args.index("--body-file") + 1]).read_text(encoding="utf-8")
self.prs = [{"state": "OPEN", "url": "https://example.test/pull/1"}]
return subprocess.CompletedProcess(args, 0, self.prs[0]["url"], "")
elif args[1:3] == ("workflow", "run"):
self.dispatches.append(args)
data = {}
else:
raise AssertionError(args)
return subprocess.CompletedProcess(args, 0, json.dumps(data), "")
args = tuple(str(self.upstream) if item == f"https://github.com/{updater.UPSTREAM}.git" else item for item in args)
return self.real_run(*args, check=check)

def update(self):
with patch.object(updater, "run", side_effect=self.fake_run), contextlib.redirect_stdout(io.StringIO()):
updater.main()

def test_clean_update_preserves_custom_and_is_repeatable(self):
self.upstream_release()
self.update()
self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "main").stdout.strip(), self.release)
self.assertEqual((self.checkout / "console.txt").read_text(), "custom console\n")
self.assertEqual((self.checkout / "official.txt").read_text(), "new official behavior\n")
self.assertNotIn("--draft", self.created_args)
self.assertEqual(len(self.dispatches), 1)
self.assertNotEqual(self.git("--git-dir", str(self.origin), "rev-parse", "custom").stdout.strip(), self.release)
self.update()
self.assertEqual(len(self.dispatches), 1)
self.assertEqual(len(self.prs), 1)

def test_conflict_creates_draft_without_claiming_validation(self):
self.upstream_release(conflict=True)
self.update()
self.assertIn("--draft", self.created_args)
self.assertIn("routing.txt", self.created_body)
self.assertEqual(self.dispatches, [])
self.assertFalse((self.checkout / ".git/MERGE_HEAD").exists())
self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "updates/upstream-v8.0.28").stdout.strip(), self.release)

def test_divergent_main_is_not_overwritten(self):
self.upstream_release()
self.git("switch", "main")
self.git("reset", "--hard", self.base)
self.write("fork-only.txt", "must not disappear\n")
self.git("add", ".")
self.git("commit", "-m", "divergent main")
divergent = self.git("rev-parse", "HEAD").stdout.strip()
self.git("push", "origin", "main")
self.git("switch", "custom")
with self.assertRaisesRegex(RuntimeError, "merge-base failed"):
self.update()
self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "main").stdout.strip(), divergent)
self.assertEqual(self.prs, [])

def test_dirty_checkout_is_rejected(self):
self.write("console.txt", "uncommitted changes\n")
with self.assertRaisesRegex(RuntimeError, "Commit or stash"):
self.update()
self.assertEqual((self.checkout / "console.txt").read_text(), "uncommitted changes\n")

def test_actions_prepares_branch_without_creating_pr(self):
self.upstream_release()
with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}):
self.update()
published = self.git("--git-dir", str(self.origin), "rev-parse", "updates/upstream-v8.0.28").stdout.strip()
self.update()
self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "updates/upstream-v8.0.28").stdout.strip(), published)
self.assertEqual(self.prs, [])
self.assertEqual(len(self.dispatches), 1)


if __name__ == "__main__":
unittest.main()
104 changes: 104 additions & 0 deletions .github/scripts/update-fork.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Mirror official main and propose a stable release update to custom."""
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile

UPSTREAM = "router-for-me/CLIProxyAPI"
FORK = "Raghav-56/CLIProxyAPI"


def run(*args, check=True):
result = subprocess.run(args, text=True, encoding="utf-8", capture_output=True)
if check and result.returncode:
raise RuntimeError(f"{args[0]} {args[1]} failed: {result.stderr.strip()}")
return result


def main():
root = run("git", "rev-parse", "--show-toplevel").stdout.strip()
os.chdir(root)
actual = json.loads(run("gh", "repo", "view", "--json", "nameWithOwner").stdout)["nameWithOwner"]
if actual != FORK:
raise RuntimeError(f"Expected {FORK}, found {actual}")
if run("git", "status", "--porcelain").stdout.strip():
raise RuntimeError("Commit or stash local changes before preparing an update.")
release = json.loads(run("gh", "api", f"repos/{UPSTREAM}/releases/latest").stdout)
tag = release["tag_name"]
if release["draft"] or release["prerelease"] or not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag):
raise RuntimeError("Expected a stable official release tag.")
run("git", "fetch", "--quiet", "--no-tags", "origin",
"refs/heads/main:refs/remotes/origin/main", "refs/heads/custom:refs/remotes/origin/custom")
run("git", "fetch", "--quiet", "--no-tags", f"https://github.com/{UPSTREAM}.git",
"refs/heads/main:refs/remotes/upstream/main", f"refs/tags/{tag}:refs/tags/{tag}")
run("git", "merge-base", "--is-ancestor", "origin/main", "upstream/main")
run("git", "push", "origin", "upstream/main:refs/heads/main")
print("main now matches official upstream/main", flush=True)
if run("git", "merge-base", "--is-ancestor", tag, "origin/custom", check=False).returncode == 0:
print(f"custom already contains {tag}")
return
branch = f"updates/upstream-{tag}"
existing = json.loads(run("gh", "pr", "list", "--repo", FORK, "--base", "custom",
"--head", branch, "--state", "all", "--json", "url,state").stdout)
if existing:
print(f"Existing update PR ({existing[0]['state']}): {existing[0]['url']}")
return
compare = f"https://github.com/{FORK}/compare/custom...{branch}?expand=1"
if run("git", "ls-remote", "--heads", "origin", branch).stdout.strip():
print(f"Existing update branch: {compare}")
return
if run("git", "show-ref", "--verify", f"refs/heads/{branch}", check=False).returncode == 0:
raise RuntimeError(f"Local branch {branch} already exists. Inspect it before retrying.")
run("git", "switch", "--create", branch, "origin/custom")
merge = run("git", "merge", "--no-ff", "--no-edit", tag, check=False)
conflicts = []
if merge.returncode:
conflicts = run("git", "diff", "--name-only", "--diff-filter=U").stdout.splitlines()
if not conflicts:
raise RuntimeError(merge.stderr.strip() or merge.stdout.strip())
run("git", "merge", "--abort")
run("git", "switch", "--detach", tag)
run("git", "branch", "--force", branch, tag)
run("git", "switch", branch)
run("git", "push", "--set-upstream", "origin", branch)
if os.environ.get("GITHUB_ACTIONS") == "true":
print(f"Prepared update branch: {compare}", flush=True)
if conflicts:
print("Merge conflicts require AI review: " + ", ".join(conflicts))
else:
run("gh", "workflow", "run", "custom-ci.yml", "--repo", FORK, "--ref", branch)
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as output:
output.write(f"main synced. [Review the release update]({compare}).\n")
return
body = (f"Update the custom proxy from official [{tag}]({release['html_url']}).\n\n"
"Keep account selection, quota guidance, and request observability working. "
"This PR does not deploy to oracler.\n\n")
if conflicts:
body += ("This draft has merge conflicts. Have an AI coding agent check out this branch, "
"merge `origin/custom`, resolve the conflicts, and follow `CUSTOM.md`.\n\n"
"Conflicting files:\n" + "".join(f"- `{name}`\n" for name in conflicts))
else:
body += "The release merged cleanly. The custom-ci workflow is dispatched on this branch. Check it before merging.\n"
with tempfile.TemporaryDirectory() as temporary:
body_path = Path(temporary) / "body.md"
body_path.write_text(body, encoding="utf-8")
args = ["gh", "pr", "create", "--repo", FORK, "--base", "custom", "--head", branch,
"--title", f"chore(upstream): merge {tag} into custom", "--body-file", str(body_path)]
if conflicts:
args.append("--draft")
url = run(*args).stdout.strip()
print(url, flush=True)
if not conflicts:
run("gh", "workflow", "run", "custom-ci.yml", "--repo", FORK, "--ref", branch)
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as output:
output.write(f"main synced with upstream. Release update: {url}\n")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions .github/workflows/agents-md-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ permissions:

jobs:
close-when-agents-md-changed:
if: github.repository == 'router-for-me/CLIProxyAPI'
runs-on: ubuntu-latest
steps:
- name: Detect AGENTS.md changes and close PR
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/auto-retarget-main-pr-to-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions:

jobs:
retarget:
if: github.actor != 'github-actions[bot]'
if: github.repository == 'router-for-me/CLIProxyAPI' && github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- name: Retarget PR base to dev
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/custom-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: custom-ci

on:
push:
branches: [custom, 'updates/**']
pull_request:
branches: [custom]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: custom-ci-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: true

jobs:
custom-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Check formatting
run: |
git diff --check
test -z "$(gofmt -l cmd/config-export internal/api/handlers/management/codex_account_policy*.go internal/api/server_accounts*.go internal/api/server_codex_account_policy_test.go internal/api/recent_requests*.go sdk/cliproxy/auth/conductor_account_policy*.go)"
- name: Test account console
run: node --test internal/api/assets/accounts.test.cjs
- name: Test upstream updater
run: python3 -B -m unittest discover -s .github/scripts -p test_update_fork.py -v
- name: Test Go packages
run: go test ./...
- name: Build server and config exporter
run: |
go build -o "$RUNNER_TEMP/cli-proxy-api" ./cmd/server
go build -o "$RUNNER_TEMP/config-export" ./cmd/config-export
1 change: 1 addition & 0 deletions .github/workflows/pr-path-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:

jobs:
ensure-no-translator-changes:
if: github.repository == 'router-for-me/CLIProxyAPI'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr-test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ permissions:

jobs:
build:
if: github.repository == 'router-for-me/CLIProxyAPI'
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ env:

jobs:
prepare-release:
if: github.repository == 'router-for-me/CLIProxyAPI'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/update-fork.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: update-fork

on:
schedule:
- cron: '17 3 * * 1'
workflow_dispatch:

permissions:
contents: write
pull-requests: read
actions: write

concurrency:
group: update-fork
cancel-in-progress: false

jobs:
prepare-update:
if: github.repository == 'Raghav-56/CLIProxyAPI'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
ref: custom
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Identify maintenance commits
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
- name: Sync official main and prepare release branch
env:
GH_TOKEN: ${{ github.token }}
run: python .github/scripts/update-fork.py
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ _bmad/*
_bmad-output/*
.gocache/
.gitnexus/
__pycache__/

# macOS
.DS_Store
Expand Down
Loading
Loading