diff --git a/.github/workflows/prepare-release-line.yml b/.github/workflows/prepare-release-line.yml new file mode 100644 index 0000000..c1b4645 --- /dev/null +++ b/.github/workflows/prepare-release-line.yml @@ -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 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 0c3b20e..bfbdfae 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -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 }} @@ -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 diff --git a/.github/workflows/version-integrity.yml b/.github/workflows/version-integrity.yml new file mode 100644 index 0000000..eb94ff2 --- /dev/null +++ b/.github/workflows/version-integrity.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b8ddbb..b406a1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/contributing.md b/docs/contributing.md index db24a76..e057fae 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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 diff --git a/docs/developer/release-management.md b/docs/developer/release-management.md new file mode 100644 index 0000000..3ca5e9a --- /dev/null +++ b/docs/developer/release-management.md @@ -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..`, while PyPI package versions +omit the leading `v`. + +| Version | Meaning | +| --- | --- | +| `v1.0.0` | First community-verified, stable public API release. | +| `v0..0` | Feature release. Before 1.0, documented breaking public-API changes also use a minor release. | +| `v0..` | 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. diff --git a/mkdocs.yml b/mkdocs.yml index 6693f37..b659211 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/scripts/release_versions.py b/scripts/release_versions.py index 777a30e..616d85a 100644 --- a/scripts/release_versions.py +++ b/scripts/release_versions.py @@ -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: @@ -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) @@ -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)): @@ -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")) @@ -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) diff --git a/tests/unit/contracts/test_release_versions.py b/tests/unit/contracts/test_release_versions.py index 48ab87b..22a2809 100644 --- a/tests/unit/contracts/test_release_versions.py +++ b/tests/unit/contracts/test_release_versions.py @@ -27,6 +27,15 @@ def test_stable_version_requires_stable_v_tag(): release_versions.stable_version("v0.3.1.dev1") +def test_development_version_helpers(): + assert release_versions.development_version("v0.4.0") == "0.4.0.dev0" + assert release_versions.next_patch_development_version("v0.3.1") == "0.3.2.dev0" + assert release_versions.version_base("0.3.1.dev5") == "0.3.1" + assert release_versions.version_base("0.3.1") == "0.3.1" + with pytest.raises(ValueError, match="unsupported release version"): + release_versions.version_base("invalid") + + def test_synchronize_updates_root_and_server_pin_only(tmp_path): root = tmp_path / "pyproject.toml" server = tmp_path / "server.toml" @@ -54,3 +63,56 @@ def test_synchronize_fails_before_writing_when_server_pin_is_missing(tmp_path): release_versions.synchronize("0.3.1.dev6", root, server) assert root.read_text() == original + + +def test_assert_synchronized_rejects_mismatched_metadata(tmp_path): + root = tmp_path / "pyproject.toml" + server = tmp_path / "server.toml" + root.write_text('[project]\nversion = "0.3.1.dev5"\n') + server.write_text('dependencies = ["opensportslib==0.3.1.dev4"]\n') + + with pytest.raises(ValueError, match="not synchronized"): + release_versions.assert_synchronized(root, server) + + +def test_validate_release_requires_matching_prepared_dev_line(tmp_path): + root = tmp_path / "pyproject.toml" + server = tmp_path / "server.toml" + root.write_text('[project]\nversion = "0.3.1.dev5"\n') + server.write_text('dependencies = ["opensportslib==0.3.1.dev5"]\n') + + assert release_versions.validate_release("v0.3.1", root, server) == "0.3.1.dev5" + with pytest.raises(ValueError, match="requires prepared version"): + release_versions.validate_release("v0.3.2", root, server) + + +def test_prepare_development_starts_newer_feature_line(tmp_path): + root = tmp_path / "pyproject.toml" + server = tmp_path / "server.toml" + root.write_text('[project]\nversion = "0.3.2.dev8"\n') + server.write_text('dependencies = ["opensportslib==0.3.2.dev8"]\n') + + assert release_versions.prepare_development("v0.4.0", root, server) == "0.4.0.dev0" + assert release_versions.assert_synchronized(root, server) == "0.4.0.dev0" + with pytest.raises(ValueError, match="must be newer"): + release_versions.prepare_development("v0.4.0", root, server) + + +def test_advance_development_moves_to_next_patch_only_from_matching_line(tmp_path): + root = tmp_path / "pyproject.toml" + server = tmp_path / "server.toml" + root.write_text('[project]\nversion = "0.3.1.dev6"\n') + server.write_text('dependencies = ["opensportslib==0.3.1.dev6"]\n') + + assert release_versions.advance_development("v0.3.1", root, server) == "0.3.2.dev0" + assert release_versions.assert_synchronized(root, server) == "0.3.2.dev0" + + +def test_advance_development_refuses_a_different_release_line(tmp_path): + root = tmp_path / "pyproject.toml" + server = tmp_path / "server.toml" + root.write_text('[project]\nversion = "0.4.0.dev0"\n') + server.write_text('dependencies = ["opensportslib==0.4.0.dev0"]\n') + + with pytest.raises(ValueError, match="requires prepared version"): + release_versions.advance_development("v0.3.1", root, server)