Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .github/labeler.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ enhancement:
- "feature/*"
- "feat/*"
- "enhancement/*"
release:
- head-branch:
- "release/*"
fix:
- head-branch:
- "fix/*"
Expand Down
25 changes: 25 additions & 0 deletions .github/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Groups the notes that `gh release create --generate-notes` writes.
changelog:
exclude:
labels:
- release
categories:
- title: New features
labels:
- enhancement
- title: Bug fixes
labels:
- fix
- hotfix
- bug
- title: Documentation
labels:
- documentation
- title: Maintenance
labels:
- DevOps
- tests
- automated
- title: Other changes
labels:
- "*"
99 changes: 99 additions & 0 deletions .github/workflows/release-pr.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Release PR

on:
workflow_dispatch:
inputs:
version:
description: "major, minor, patch, or an explicit version such as 1.2.3"
type: string
default: patch

permissions:
contents: read

jobs:
release-pr:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
# A release always comes from `main`. The full history and the tags
# give the previous tag and the changes after it.
ref: main
fetch-depth: 0
persist-credentials: false

- name: Set the new version
id: version
env:
REQUEST: ${{ inputs.version }}
run: |
version="$(python3 tools/version.py --set "$REQUEST")"
if git tag --list "v$version" "v$version-*" | grep -q .; then
echo "::error::A tag for version $version exists already."
exit 1
fi
{
echo "version=$version"
echo "previous=$(git describe --tags --abbrev=0)"
} >> "$GITHUB_OUTPUT"

- name: Write the pull request body
env:
BODY_PATH: ${{ runner.temp }}/release-pr-body.md
PREVIOUS: ${{ steps.version.outputs.previous }}
VERSION: ${{ steps.version.outputs.version }}
run: |
# The heredoc expands shell variables only. A commit subject cannot
# inject a command, because the shell never re-reads an expansion.
changelog="$(git log --pretty=format:'- %s' "$PREVIOUS"..HEAD)"
cat >"$BODY_PATH" <<BODY
## Purpose
Release LuxonisML \`v${VERSION}\`. The \`Release PR\` workflow opened this
pull request, and the merge of it starts the release.

## Specification
The version in \`luxonis_ml/__init__.py\` becomes \`${VERSION}\`.

Changes after \`${PREVIOUS}\`:

${changelog}

## Dependencies & Potential Impact
This pull request changes the version only. The new version then goes to
PyPI under the same package name.

## Deployment Plan
1. Approve and merge this pull request.
2. The \`Release Tag\` workflow creates the tag, the release, and the notes.
3. The release event starts \`Upload Python Package\` and
\`Deploy Documentation\`.

## Testing & Validation
CI runs on this pull request. It includes \`check-requirements\`, which
only runs on \`release/*\` branches.

## AI Usage
The \`Release PR\` workflow generated this pull request. No AI tool wrote it.

Submitted code was reviewed by a human: YES/NO

The author is taking the responsibility for the contribution: YES/NO
BODY

- name: Create the pull request
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
# A pull request from the default token does not start CI. The
# personal token makes the checks run, `check-requirements` included.
token: ${{ secrets.WORKFLOW_SECRET }}
base: main
branch: release/v${{ steps.version.outputs.version }}
delete-branch: true
add-paths: luxonis_ml/__init__.py
commit-message: "chore: bump the version to ${{ steps.version.outputs.version }}"
title: "LuxonisML `v${{ steps.version.outputs.version }}`"
body-path: ${{ runner.temp }}/release-pr-body.md
labels: release
46 changes: 46 additions & 0 deletions .github/workflows/release-tag.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Release Tag

on:
pull_request:
types: [closed]
branches: [main]

permissions:
contents: read

env:
# A tag-name convention that every release since v0.3.0 keeps. It does not
# mark the GitHub release as a prerelease, and the PyPI version stays
# `X.Y.Z`. Change both deliberately, not through this constant alone.
TAG_SUFFIX: "-beta"

jobs:
release:
if: >-
github.event.pull_request.merged &&
startsWith(github.event.pull_request.head.ref, 'release/')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false

- name: Read the version
id: version
run: echo "version=$(python3 tools/version.py)" >> "$GITHUB_OUTPUT"

- name: Create the release
env:
# The default token cannot start another workflow. The personal
# token makes the release event start the PyPI upload and the
# documentation deployment.
GH_TOKEN: ${{ secrets.WORKFLOW_SECRET }}
TAG: v${{ steps.version.outputs.version }}${{ env.TAG_SUFFIX }}
TARGET: ${{ github.event.pull_request.merge_commit_sha }}
run: |
gh release create "$TAG" \
--target "$TARGET" \
--title "$TAG" \
--generate-notes
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ the uv override that removes `opencv-python-headless`. See the comment in
| `tests` | Pytest suite, fixtures, integration tests, and data workflow coverage. |
| `tools/build_pydoctor_docs.py` | The local and CI entrypoint for generated API docs. |
| `tools/export_requirements.sh` | Regenerates `uv.lock` and the `requirements*.txt` exports. |
| `tools/version.py` | Reports the package version, or changes it for a release. |

Package dependencies are defined entirely in `pyproject.toml`: runtime
requirements in `[project.dependencies]`, the per-module and cloud extras in
Expand Down Expand Up @@ -187,8 +188,32 @@ selected extras.

## Releases

A release needs two manual steps: start the workflow, then merge the pull
request it opens.

1. Run the `Release PR` workflow from the Actions tab. Give it `major`,
`minor`, `patch`, or an explicit version such as `1.2.3`.
1. The workflow changes `__version__` in `luxonis_ml/__init__.py` and opens a
`release/vX.Y.Z` pull request that lists the changes after the last tag.
1. Review the pull request, wait for CI, and merge it.
1. The `Release Tag` workflow then tags `vX.Y.Z-beta` on the merge commit and
creates the GitHub release with generated notes.
1. The release publication starts the PyPI upload and the docs deployment.

Use `tools/version.py` for the same version change on your machine:

```bash
python3 tools/version.py # report the current version
python3 tools/version.py --set minor
```

`.github/release.yaml` groups the generated notes into categories from the pull
request labels. GitHub reads that file; the name is not ours to choose.

Package publishing and documentation deployment are handled by GitHub Actions:

- `release-pr.yaml` opens the version bump pull request on manual dispatch.
- `release-tag.yaml` tags and releases a merged `release/*` pull request.
- `python-publish.yml` builds and publishes on release publication or manual
dispatch.
- `docs-pages.yaml` publishes GitHub Pages docs on `main`, release publication,
Expand Down
109 changes: 109 additions & 0 deletions tools/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Read or change the version in ``luxonis_ml/__init__.py``.

Run it from the repository root.
"""

import argparse
import ast
import re
import sys
from pathlib import Path

INIT_PATH = Path("luxonis_ml/__init__.py")
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")


def main() -> None:
args = parse_args()
current = read_version()

if args.new_version is None:
sys.stdout.write(f"{current}\n")
return

new_version = resolve_version(current, args.new_version)
write_version(new_version)
sys.stdout.write(f"{new_version}\n")


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Read or change the LuxonisML version."
)
parser.add_argument(
"--set",
dest="new_version",
metavar="VERSION",
help=(
"Increase one part of the version with 'major', 'minor', or "
"'patch', or give an explicit version such as '1.2.3'. Without "
"this option the tool only reports the current version."
),
)
return parser.parse_args()


def read_version() -> str:
return str(_version_node(INIT_PATH.read_text(encoding="utf-8")).value)


def resolve_version(current: str, request: str) -> str:
major, minor, patch = parse_version(current)
if request == "major":
new_version = (major + 1, 0, 0)
elif request == "minor":
new_version = (major, minor + 1, 0)
elif request == "patch":
new_version = (major, minor, patch + 1)
else:
new_version = parse_version(request)

if new_version <= (major, minor, patch):
raise SystemExit(
f"Version {_format_version(new_version)} is not above the "
f"current version {current}."
)
return _format_version(new_version)


def parse_version(version: str) -> tuple[int, int, int]:
if not VERSION_PATTERN.match(version):
raise SystemExit(
f"Expected 'major', 'minor', 'patch', or a version of the form "
f"'1.2.3'. Got {version!r}."
)
major, minor, patch = (int(part) for part in version.split("."))
return major, minor, patch


def write_version(new_version: str) -> None:
text = INIT_PATH.read_text(encoding="utf-8")
node = _version_node(text)
lines = text.splitlines(keepends=True)
line = lines[node.lineno - 1]
prefix = line[: node.col_offset]
suffix = line[node.end_col_offset :]
lines[node.lineno - 1] = f'{prefix}"{new_version}"{suffix}'
INIT_PATH.write_text("".join(lines), encoding="utf-8")


def _format_version(version: tuple[int, int, int]) -> str:
return ".".join(str(part) for part in version)


def _version_node(text: str) -> ast.Constant:
for node in ast.parse(text).body:
if (
isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
and node.target.id == "__version__"
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
):
return node.value
raise SystemExit(f"Found no `__version__` string in {INIT_PATH}.")


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