ci: cut rc/<X.Y.0> off main every Friday at 3am Pacific (#43121)

* ci: cut rc/<X.Y.0> off main every Friday at 3am Pacific

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: refuse to cut rc branch from a ref other than main

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: move rc version check into .github/scripts/read_rc_version.py with tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: fall back to tomli for the rc version script on Python 3.10

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: type the read_rc_version test helper

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yuneng <yuneng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-24 21:57:20 -07:00 • committed by GitHub
parent c976c16a82
commit c601dfc134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 159 additions and 0 deletions

44
.github/scripts/read_rc_version.py vendored Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Print `version=X.Y.0` from [project].version in pyproject.toml for $GITHUB_OUTPUT.
Usage
-----
python3 read_rc_version.py [path/to/pyproject.toml] >> "$GITHUB_OUTPUT"
Exit code 1 with a `::error::` line on stderr when the version is not an X.Y.0 release.
"""
from __future__ import annotations
import pathlib
import re
import sys
from typing import Final
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
RELEASE_VERSION: Final = re.compile(r"[0-9]+\.[0-9]+\.0")
def read_version(pyproject: pathlib.Path) -> str:
with pyproject.open("rb") as f:
return tomllib.load(f)["project"]["version"]
def main(argv: list[str]) -> int:
pyproject: Final = pathlib.Path(argv[1]) if len(argv) > 1 else pathlib.Path("pyproject.toml")
version: Final = read_version(pyproject)
if RELEASE_VERSION.fullmatch(version) is None:
print( # noqa: T201 # the ::error:: line to stderr is the workflow's failure signal
f"::error::pyproject.toml version {version} is not an X.Y.0 release version", file=sys.stderr
)
return 1
print(f"version={version}") # noqa: T201 # stdout line is appended to $GITHUB_OUTPUT
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

66
.github/workflows/create-rc-branch.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: Create RC Branch
on:
schedule:
- cron: "0 3 * * 5"
timezone: "America/Los_Angeles"
workflow_dispatch:
permissions: {}
jobs:
create-rc-branch:
name: Create RC Branch
if: github.event_name != 'schedule' || github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Require main
env:
REF: ${{ github.ref }}
run: |
if [ "$REF" != "refs/heads/main" ]; then
echo "::error::rc branches are cut from refs/heads/main only, got $REF"
exit 1
fi
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Read release version
id: version
run: python3 .github/scripts/read_rc_version.py >> "$GITHUB_OUTPUT"
- name: Create rc branch
env:
VERSION: ${{ steps.version.outputs.version }}
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const branchName = `rc/${process.env.VERSION}`;
const ref = `heads/${branchName}`;
const existing = await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref,
}).catch((error) => {
if (error.status === 404) {
return null;
}
throw error;
});
if (existing !== null) {
core.setFailed(`Branch ${branchName} already exists at ${existing.data.object.sha}; leaving it untouched`);
return;
}
await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `refs/${ref}`,
sha: context.sha,
});
core.info(`Created branch ${branchName} at ${context.sha}`);

View file

@ -0,0 +1,49 @@
"""Tests for .github/scripts/read_rc_version.py."""
import importlib.util
import sys
from pathlib import Path
from typing import Final
import pytest
_REPO_ROOT: Final = Path(__file__).resolve().parents[2]
_MODULE_PATH: Final = _REPO_ROOT / ".github" / "scripts" / "read_rc_version.py"
_spec: Final = importlib.util.spec_from_file_location("read_rc_version", _MODULE_PATH)
read_rc_version: Final = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = read_rc_version
_spec.loader.exec_module(read_rc_version)
def _run(tmp_path: Path, version: str, capsys: pytest.CaptureFixture[str]) -> tuple[int, str, str]:
pyproject: Final = tmp_path / "pyproject.toml"
pyproject.write_text(f'[project]\nname = "litellm"\nversion = "{version}"\n', encoding="utf-8")
code: Final = read_rc_version.main(["read_rc_version.py", str(pyproject)])
captured: Final = capsys.readouterr()
return code, captured.out, captured.err
@pytest.mark.parametrize("version", ["1.104.0", "2.0.0", "10.250.0"])
def test_an_x_y_0_version_is_printed_as_a_github_output_line(
tmp_path: Path, capsys: pytest.CaptureFixture[str], version: str
) -> None:
code, out, err = _run(tmp_path, version, capsys)
assert (code, out, err) == (0, f"version={version}\n", "")
@pytest.mark.parametrize("version", ["1.104.1", "1.104.0rc1", "1.104", "v1.104.0", "1.104.0.dev1"])
def test_a_non_release_version_exits_1_without_printing_a_version(
tmp_path: Path, capsys: pytest.CaptureFixture[str], version: str
) -> None:
code, out, err = _run(tmp_path, version, capsys)
assert code == 1
assert out == ""
assert err == f"::error::pyproject.toml version {version} is not an X.Y.0 release version\n"
def test_the_repo_pyproject_is_read_when_no_path_is_given(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.chdir(_REPO_ROOT)
assert read_rc_version.main(["read_rc_version.py"]) == 0
assert capsys.readouterr().out == f"version={read_rc_version.read_version(_REPO_ROOT / 'pyproject.toml')}\n"