Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
push:
branches:
- master
- 'release/**'
tags:
- '*'

Expand All @@ -15,6 +16,8 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # hatch-vcs needs full history + tags to derive version
- uses: actions/setup-python@v5
with:
python-version: '3.11'
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ share/python-wheels/
MANIFEST
uv.lock

# Generated by hatch-vcs at build time
src/cvxpylayers/_version.py

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
Expand Down
96 changes: 96 additions & 0 deletions PROCEDURES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Procedures for making a new cvxpylayers release

This file provides the procedures for releasing a new version of cvxpylayers.
The process involves tagging a new release in the commit history,
packaging and deploying the updated source code, and publishing release notes.

## Versioning

cvxpylayers uses [hatch-vcs](https://github.com/ofek/hatch-vcs) to derive the
package version from git tags. Concretely:

- A tagged commit `vX.Y.Z` produces version `X.Y.Z`.
- Untagged commits produce a [PEP 440 dev version](https://peps.python.org/pep-0440/#developmental-releases)
of the form `X.Y.Z.devN+gHASH` where `N` is the number of commits since the
previous tag.

There is no version constant to bump by hand. The single source of truth is the
git tag. At build time, hatch-vcs writes the resolved version to
`src/cvxpylayers/_version.py` (gitignored), which the package imports at runtime.

## Defining a new release

### Incrementing the MINOR version number

Let's say we're releasing 1.2.0.

1. Starting from `master`, checkout a new branch called `release/1.2.x` and push
it. The branch protections / CI on `release/**` are configured in
`.github/workflows/build.yml`.
2. On `release/1.2.x`, tag the head commit as `v1.2.0` and push the tag:
```
git tag -a v1.2.0 -m "v1.2.0"
git push origin v1.2.0
```
This triggers the PyPI deploy (see below).
3. Future patches to the 1.2 line happen on `release/1.2.x` via cherry-picks
from master — see the next section.
4. Continue feature development on `master`. No version bump is required there;
hatch-vcs will produce `1.2.0.devN+gHASH` versions automatically until the
next tag.

### Incrementing the MICRO version number (a.k.a., releasing a patch)

Let's say we're releasing cvxpylayers 1.2.1.

1. Create a new branch `patch/1.2.1` from `release/1.2.x`. Go through all
commits merged into `master` since `v1.2.0` and cherry-pick the ones that
belong in the patch: `git cherry-pick abc123`. Open a pull request against
`release/1.2.x` listing the included commits.
2. After the PR merges, tag the head of `release/1.2.x` as `v1.2.1` and push
the tag:
```
git tag -a v1.2.1 -m "v1.2.1"
git push origin v1.2.1
```

## Deploying a release to PyPI

Deployments to PyPI are automatically triggered for every tagged commit by the
GitHub Actions workflow at `.github/workflows/build.yml`. The progress of the
deploy can be inspected by opening the workflow run marked with `v*` from the
[actions tab](https://github.com/cvxpy/cvxpylayers/actions).

After a successful deployment, verify the result on
[PyPI](https://pypi.org/project/cvxpylayers/) — both the source distribution
and the wheel should be present, with the correct version.

If the action fails intermittently (e.g., a dependency install times out), it
can be retriggered from the actions tab.

## Creating a release on GitHub

Go to the [Releases](https://github.com/cvxpy/cvxpylayers/releases) tab and
click "Draft a new release". Select the previously created tag and write
release notes.

For minor releases, this includes a summary of new features and deprecations.
We additionally list the PRs contained in the release and their contributors.
For patch releases, list the cherry-picked fixes.

To generate the list of PRs and contributors, use the `tools/release_notes.py`
script:

```
python tools/release_notes.py v1.2.0 # minor release
python tools/release_notes.py v1.2.1 # patch release
```

For minor releases, the script automatically excludes PRs that were
cherry-picked into the previous release branch's patch releases. For patch
releases, it compares against the previous patch tag on the same release
branch.

Take care to select "set as the latest release" only for minor releases or
patches to the most recent minor release. Patches to older release lines
should not become the "latest".
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
Expand Down Expand Up @@ -171,4 +171,7 @@ known-first-party = ["cvxpylayers"]
max-complexity = 25

[tool.hatch.version]
path = "src/cvxpylayers/__init__.py"
source = "vcs"

[tool.hatch.build.hooks.vcs]
version-file = "src/cvxpylayers/_version.py"
5 changes: 4 additions & 1 deletion src/cvxpylayers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
__version__ = "1.1.0"
try:
from cvxpylayers._version import __version__ # generated by hatch-vcs at build time
except ImportError:
__version__ = "0.0.0+unknown"

import cvxpylayers._quad_form_dpp # noqa: F401 # apply monkey-patches at import time
184 changes: 184 additions & 0 deletions tools/release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""
Copyright, the CVXPY authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Generate the "PR by author" list for cvxpylayers release notes.

Compares the release tag against the previous release branch so that
cherry-picked patch PRs are not duplicated.

Usage:
python tools/release_notes.py v1.2.0 release/1.1.x
python tools/release_notes.py v1.2.0 # auto-detects previous release branch
"""

import json
import re
import subprocess
import sys


def run(cmd: list[str]) -> str:
return subprocess.check_output(cmd, text=True).strip()


def parse_tag(tag: str) -> tuple[int, int, int]:
"""Parse a tag like v1.2.0 into (major, minor, micro)."""
m = re.match(r"v(\d+)\.(\d+)\.(\d+)", tag)
if not m:
raise ValueError(f"Cannot parse tag: {tag}")
return int(m.group(1)), int(m.group(2)), int(m.group(3))


def get_previous_release_branch(tag: str) -> str:
"""Infer the previous release branch from a tag like v1.2.0."""
major, minor, _ = parse_tag(tag)
return f"origin/release/{major}.{minor - 1}.x"


def get_previous_release_tag(tag: str) -> str:
"""Infer the previous minor release tag from a tag like v1.2.0."""
major, minor, _ = parse_tag(tag)
return f"v{major}.{minor - 1}.0"


def extract_pr_numbers(log_output: str) -> set[int]:
"""Extract PR numbers from git log output.

Matches the (#NNNN) pattern at the end of a line, which is the
convention for merge and squash commit subject lines. This avoids
false positives from inline issue references like "Closes #1000".
"""
prs = set()
for m in re.finditer(r"\(#(\d+)\)\s*$", log_output, re.MULTILINE):
prs.add(int(m.group(1)))
return prs


def get_pr_author(pr_number: int) -> str | None:
"""Look up the GitHub username for a PR."""
try:
result = run([
"gh", "pr", "view", str(pr_number),
"--json", "author", "-q", ".author.login",
])
return result
except subprocess.CalledProcessError:
return None


def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <tag>")
print(f" e.g. {sys.argv[0]} v1.2.0 (minor release)")
print(f" e.g. {sys.argv[0]} v1.1.5 (patch release)")
sys.exit(1)

tag = sys.argv[1]
major, minor, micro = parse_tag(tag)

if micro == 0:
# Minor release: compare against previous minor's .0 tag,
# excluding PRs cherry-picked onto the previous release branch.
prev_tag = get_previous_release_tag(tag)
prev_branch = get_previous_release_branch(tag)

release_log = run(
["git", "log", "--format=%B", f"{prev_tag}..{tag}"]
)
release_prs = extract_pr_numbers(release_log)

patch_log = run(
["git", "log", "--format=%B", f"{prev_tag}..{prev_branch}"]
)
patch_prs = extract_pr_numbers(patch_log)

pr_numbers = sorted(release_prs - patch_prs)

print(
f"Found {len(release_prs)} PRs in {prev_tag}..{tag}, "
f"excluding {len(patch_prs)} patch PRs from "
f"{prev_branch}, {len(pr_numbers)} remaining.",
file=sys.stderr,
)
else:
# Patch release: compare against the previous tag on the
# same release branch.
prev_tag = f"v{major}.{minor}.{micro - 1}"

release_log = run(
["git", "log", "--format=%B", f"{prev_tag}..{tag}"]
)
pr_numbers = sorted(extract_pr_numbers(release_log))

print(
f"Found {len(pr_numbers)} PRs in {prev_tag}..{tag}.",
file=sys.stderr,
)

print("Looking up authors...", file=sys.stderr)

# Look up authors via gh CLI, batch with graphql to reduce API calls
author_prs: dict[str, list[int]] = {}
skipped: list[int] = []

# Use graphql to batch lookups (100 at a time)
for i in range(0, len(pr_numbers), 100):
batch = pr_numbers[i:i + 100]
# Build a graphql query for this batch
parts = []
for j, pr in enumerate(batch):
parts.append(
f'pr{j}: pullRequest(number: {pr}) {{ author {{ login }} }}'
)
query = "{ repository(owner: \"cvxpy\", name: \"cvxpylayers\") { " + " ".join(parts) + " } }"
try:
result = run(["gh", "api", "graphql", "-f", f"query={query}"])
data = json.loads(result)["data"]["repository"]
for j, pr in enumerate(batch):
node = data.get(f"pr{j}")
if node and node.get("author"):
login = node["author"]["login"]
if "dependabot" in login:
continue
author_prs.setdefault(login, []).append(pr)
else:
skipped.append(pr)
except subprocess.CalledProcessError:
# Fall back to individual lookups
for pr in batch:
author = get_pr_author(pr)
if author is None:
skipped.append(pr)
elif "dependabot" in author:
continue
else:
author_prs.setdefault(author, []).append(pr)

# Count PRs (excluding dependabot and skipped)
total_prs = sum(len(prs) for prs in author_prs.values())
total_authors = len(author_prs)

print(f"\nThis new release totaled {total_prs} PRs from {total_authors} contributors.\n")
for author in sorted(author_prs, key=lambda a: a.lower()):
prs = ", ".join(f"#{pr}" for pr in sorted(author_prs[author]))
print(f"- @{author} | {prs}")

if skipped:
skipped_str = ", ".join(f"#{pr}" for pr in skipped)
print(f"\nSkipped PRs (not found): {skipped_str}", file=sys.stderr)


if __name__ == "__main__":
main()
Loading