Skip to content
Merged
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
39 changes: 39 additions & 0 deletions .github/workflows/prepare-release-line.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Prepare Development Release Line

on:
workflow_dispatch:
inputs:
release_tag:
description: "Target release tag (for example, v0.4.0)"
required: true
type: string

concurrency:
group: prepare-release-line
cancel-in-progress: false

permissions:
contents: write

jobs:
prepare:
runs-on: ubuntu-latest
steps:
- name: Checkout dev
uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0

- name: Validate and initialize development line
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: python scripts/release_versions.py prepare-dev "$RELEASE_TAG"

- name: Commit prepared development version
run: |
git config user.name "github-actions"
git config user.email "actions@github.com"
git add pyproject.toml server/pyproject.toml
git commit -m "chore: prepare development release line"
git push origin HEAD:dev
32 changes: 32 additions & 0 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ jobs:
python -m pip install --upgrade pip
pip install build twine

- name: Require the release tag to match main
run: |
git fetch origin main --depth=1
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "::error::Publish a release only from the current main commit."
exit 1
fi

- name: Validate prepared release version
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: python scripts/release_versions.py validate-release "$RELEASE_TAG"

- name: Synchronize stable version for build
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
Expand Down Expand Up @@ -65,3 +78,22 @@ jobs:
git commit -m "fix: synchronize stable release version"
git push origin main
fi

- name: Checkout latest dev branch
uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0

- name: Advance dev to the next patch release line
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: python scripts/release_versions.py advance-dev "$RELEASE_TAG"

- name: Commit next development version to dev
run: |
git config user.name "github-actions"
git config user.email "actions@github.com"
git add pyproject.toml server/pyproject.toml
git commit -m "chore: start next development version"
git push origin HEAD:dev
38 changes: 38 additions & 0 deletions .github/workflows/version-integrity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Version Integrity

on:
pull_request:
branches: [dev]
paths:
- "pyproject.toml"
- "server/pyproject.toml"

permissions:
contents: read

jobs:
protected-version-metadata:
name: Reject contributor version changes
runs-on: ubuntu-latest
steps:
- name: Checkout pull request merge revision
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Compare protected metadata with dev
env:
BASE_REF: ${{ github.base_ref }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
git fetch origin "$BASE_REF" --depth=1
base_root=$(git show "origin/$BASE_REF:pyproject.toml" | sed -n '/^\[project\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*//p')
head_root=$(git show "$HEAD_SHA:pyproject.toml" | sed -n '/^\[project\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*//p')
base_pin=$(git show "origin/$BASE_REF:server/pyproject.toml" | sed -n 's/.*"opensportslib==\([^"]*\)".*/\1/p')
head_pin=$(git show "$HEAD_SHA:server/pyproject.toml" | sed -n 's/.*"opensportslib==\([^"]*\)".*/\1/p')

if [ "$base_root" != "$head_root" ] || [ "$base_pin" != "$head_pin" ]; then
echo "::error::Version metadata is managed by GitHub Actions. Do not change pyproject.toml project.version or the opensportslib server dependency pin in a PR to dev."
exit 1
fi
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ git push origin feature/your-feature-name
### 5. Open Pull Request (PR) → dev
Raise a Pull Request (PR) to merge your branch back into the `dev` branch.

Package versions are managed by GitHub Actions. Do not change the root
`pyproject.toml` package version or the OpenSportsLib dependency pin in
`server/pyproject.toml` in a PR to `dev`; the required Version Integrity check
will reject those edits. Maintainers should follow the
[release-management guide](docs/developer/release-management.md) for feature
release-line preparation and stable releases.

### Contributor License Agreement

Every distinct GitHub-linked commit author in a PR targeting `dev` must accept
Expand Down
7 changes: 7 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ git push origin feature/your-feature-name
### 5. Open Pull Request (PR) → dev
Raise a Pull Request (PR) to merge your branch back into the `dev` branch.

Package versions are managed by GitHub Actions. Do not change the root
`pyproject.toml` package version or the OpenSportsLib dependency pin in
`server/pyproject.toml` in a PR to `dev`; the required Version Integrity check
will reject those edits. Maintainers should follow the
[release-management guide](developer/release-management.md) for feature
release-line preparation and stable releases.

Every GitHub-linked commit author must accept the Individual CLA when prompted
by the `CLA check`. Post the exact signing phrase configured in
`.github/cla.yml`; see `.github/CLA.md` for the agreement. Run the supported
Expand Down
59 changes: 59 additions & 0 deletions docs/developer/release-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Release Management

OpenSportsLib release metadata is managed by GitHub Actions. Contributors must
not edit the root package version or the inference server's OpenSportsLib
dependency pin in a pull request to `dev`; the required **Version Integrity**
check rejects those changes.

## Version policy

GitHub release tags use `v<major>.<minor>.<patch>`, while PyPI package versions
omit the leading `v`.

| Version | Meaning |
| --- | --- |
| `v1.0.0` | First community-verified, stable public API release. |
| `v0.<minor>.0` | Feature release. Before 1.0, documented breaking public-API changes also use a minor release. |
| `v0.<minor>.<patch>` | Backward-compatible bug-fix release. |
| `X.Y.Z.devN` | Development prerelease published from `dev`. |

## Automated workflows

| Workflow | Trigger | Result |
| --- | --- | --- |
| **CI Fast Tests** | Called by pull-request and branch workflows | Installs the package and runs `bash scripts/run_tests.sh`. |
| **CLA automation** | PRs to `dev` and PR comments | Checks that every GitHub-linked commit author accepted the CLA. |
| **Version Integrity** | PRs to `dev` that touch package metadata | Rejects contributor changes to managed version fields. |
| **Deploy Docs** | Documentation PRs; pushes to `main` | Strictly builds the documentation, then deploys it after a `main` push. |
| **Auto Pre-release Publish to PyPI** | Pushes to `dev` | Tests, increments `.devN`, synchronizes metadata, and publishes a prerelease. |
| **Publish Stable Release to PyPI** | Published GitHub Release | Validates, builds, and publishes the tagged stable release; updates `main` and advances `dev`. |
| **Prepare Development Release Line** | Maintainer workflow dispatch | Starts a chosen newer feature-release line at `X.Y.Z.dev0`. |

## Release procedure

1. For a feature release, a maintainer opens **Actions → Prepare Development
Release Line**, enters a tag such as `v0.4.0`, and runs it. The workflow
validates the target is newer than the current line and commits
`0.4.0.dev0` to `dev` as `github-actions[bot]`.
2. Contributors merge normal changes through PRs to `dev`. Each `dev` push
runs tests and publishes the next development version such as
`0.4.0.dev1`, `0.4.0.dev2`, and so on.
3. During a release freeze, promote the prepared `dev` commit to `main`.
Create and publish GitHub Release `vX.Y.Z` from that current `main` commit.
4. Stable-release automation requires the tag source to be `X.Y.Z.devN` with
synchronized metadata, builds package `X.Y.Z`, and publishes it to PyPI.
It then records `X.Y.Z` on `main`.
5. Finally, the same workflow verifies that `dev` is still on the released
line and advances it to `X.Y.(Z+1).dev0`. For example, publishing `v0.3.1`
automatically changes `dev` to `0.3.2.dev0`.

If `dev` was already moved to a different release line, stable-release
automation fails before modifying it. Resolve that release-line conflict rather
than overwriting version metadata.

## Repository settings

Protect `dev` by requiring pull requests, CI, CLA check, and **Version
Integrity**. Do not allow people to push directly to `dev` or `main`; permit
only the `github-actions[bot]` version-only commits required by these workflows.
The PyPI token remains configured as the `PYPI_API_TOKEN` repository secret.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ nav:
- Tools: developer/tools-reference.md
- Internal Modules: developer/internal-catalog.md
- Testing & Review: developer/testing.md
- Release Management: developer/release-management.md
- Configuration Authoring: config/developer-guide.md
- Contributing: contributing.md

Expand Down
111 changes: 104 additions & 7 deletions scripts/release_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
PROJECT_VERSION = re.compile(
r'(?ms)(^\[project\]\s*.*?^version\s*=\s*")[^"]+("\s*$)'
)
SERVER_PIN = re.compile(r'("opensportslib==)[^"]+("[,\s]*$)', re.MULTILINE)
SERVER_PIN = re.compile(r'("opensportslib==)([^"]+)(")')


def next_dev_version(current: str) -> str:
Expand All @@ -33,6 +33,27 @@ def stable_version(tag: str) -> str:
return version


def development_version(tag: str) -> str:
"""Return the initial development version for a stable release tag."""
return f"{stable_version(tag)}.dev0"


def next_patch_development_version(tag: str) -> str:
"""Return the next patch's initial development version for a release tag."""
major, minor, patch = (int(part) for part in stable_version(tag).split("."))
return f"{major}.{minor}.{patch + 1}.dev0"


def version_base(version: str) -> str:
"""Return X.Y.Z from either a stable or development package version."""
dev_match = DEV_VERSION.fullmatch(version)
if dev_match:
return dev_match.group("base")
if STABLE_VERSION.fullmatch(version):
return version
raise ValueError(f"unsupported release version {version!r}")


def read_project_version(pyproject: Path) -> str:
text = pyproject.read_text()
match = PROJECT_VERSION.search(text)
Expand All @@ -41,6 +62,56 @@ def read_project_version(pyproject: Path) -> str:
return text[match.start(0) + len(match.group(1)) : match.end(0) - len(match.group(2))]


def read_server_pin(pyproject: Path) -> str:
"""Return the OpenSportsLib dependency pin from the server metadata."""
text = pyproject.read_text()
matches = SERVER_PIN.findall(text)
if len(matches) != 1:
raise ValueError(f"expected one opensportslib dependency pin in {pyproject}")
return matches[0][1]


def assert_synchronized(root_pyproject: Path, server_pyproject: Path) -> str:
"""Ensure the package version and server dependency pin are identical."""
version = read_project_version(root_pyproject)
pin = read_server_pin(server_pyproject)
if version != pin:
raise ValueError(
f"version metadata is not synchronized: {root_pyproject} has {version!r}, "
f"but {server_pyproject} pins {pin!r}"
)
return version


def validate_release(tag: str, root_pyproject: Path, server_pyproject: Path) -> str:
"""Validate that metadata is the prepared prerelease for *tag*."""
expected = development_version(tag)
current = assert_synchronized(root_pyproject, server_pyproject)
if not DEV_VERSION.fullmatch(current) or version_base(current) != stable_version(tag):
raise ValueError(
f"release tag {tag!r} requires prepared version {expected[:-1]}N, got {current!r}"
)
return current


def prepare_development(tag: str, root_pyproject: Path, server_pyproject: Path) -> str:
"""Start the requested release line at X.Y.Z.dev0."""
target = development_version(tag)
current = assert_synchronized(root_pyproject, server_pyproject)
if tuple(map(int, stable_version(tag).split("."))) <= tuple(map(int, version_base(current).split("."))):
raise ValueError(f"target release {tag!r} must be newer than current version {current!r}")
synchronize(target, root_pyproject, server_pyproject)
return target


def advance_development(tag: str, root_pyproject: Path, server_pyproject: Path) -> str:
"""Advance a released development line to its next patch development base."""
validate_release(tag, root_pyproject, server_pyproject)
target = next_patch_development_version(tag)
synchronize(target, root_pyproject, server_pyproject)
return target


def synchronize(version: str, root_pyproject: Path, server_pyproject: Path) -> None:
"""Write one OpenSportsLib version to both release metadata files."""
if not (STABLE_VERSION.fullmatch(version) or DEV_VERSION.fullmatch(version)):
Expand All @@ -54,14 +125,24 @@ def synchronize(version: str, root_pyproject: Path, server_pyproject: Path) -> N
raise ValueError(f"expected one opensportslib dependency pin in {server_pyproject}")

root_updated = PROJECT_VERSION.sub(rf"\g<1>{version}\g<2>", root_text)
server_updated = SERVER_PIN.sub(rf"\g<1>{version}\g<2>", server_text)
server_updated = SERVER_PIN.sub(rf"\g<1>{version}\g<3>", server_text)
root_pyproject.write_text(root_updated)
server_pyproject.write_text(server_updated)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=("bump-dev", "set-stable"))
parser.add_argument(
"command",
choices=(
"bump-dev",
"set-stable",
"prepare-dev",
"validate-release",
"advance-dev",
"assert-synchronized",
),
)
parser.add_argument("value", nargs="?", help="vX.Y.Z tag for set-stable")
parser.add_argument("--root", type=Path, default=Path("pyproject.toml"))
parser.add_argument("--server", type=Path, default=Path("server/pyproject.toml"))
Expand All @@ -70,13 +151,29 @@ def main() -> None:
if args.command == "bump-dev":
if args.value is not None:
parser.error("bump-dev does not accept a value")
version = next_dev_version(read_project_version(args.root))
else:
version = next_dev_version(assert_synchronized(args.root, args.server))
synchronize(version, args.root, args.server)
elif args.command == "set-stable":
if args.value is None:
parser.error("set-stable requires a vX.Y.Z tag")
version = stable_version(args.value)

synchronize(version, args.root, args.server)
synchronize(version, args.root, args.server)
elif args.command == "prepare-dev":
if args.value is None:
parser.error("prepare-dev requires a vX.Y.Z tag")
version = prepare_development(args.value, args.root, args.server)
elif args.command == "validate-release":
if args.value is None:
parser.error("validate-release requires a vX.Y.Z tag")
version = validate_release(args.value, args.root, args.server)
elif args.command == "advance-dev":
if args.value is None:
parser.error("advance-dev requires a vX.Y.Z tag")
version = advance_development(args.value, args.root, args.server)
else:
if args.value is not None:
parser.error("assert-synchronized does not accept a value")
version = assert_synchronized(args.root, args.server)
print(version)


Expand Down
Loading
Loading