diff --git a/.github/labeler.yaml b/.github/labeler.yaml index f8a37f3f..dc680f82 100644 --- a/.github/labeler.yaml +++ b/.github/labeler.yaml @@ -36,6 +36,9 @@ enhancement: - "feature/*" - "feat/*" - "enhancement/*" +release: + - head-branch: + - "release/*" fix: - head-branch: - "fix/*" diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 00000000..1f12b64b --- /dev/null +++ b/.github/release.yaml @@ -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: + - "*" diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 28daac15..aa282b58 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -10,6 +10,9 @@ permissions: jobs: deploy: + # The `pypi` environment holds a required reviewer. This job waits for an + # approval before it uploads, whatever started it. + environment: pypi runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/.github/workflows/release-pr.yaml b/.github/workflows/release-pr.yaml new file mode 100644 index 00000000..a18910a4 --- /dev/null +++ b/.github/workflows/release-pr.yaml @@ -0,0 +1,19 @@ +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 + +jobs: + release-pr: + uses: ./.github/workflows/reusable-release-pr.yaml + with: + version: ${{ inputs.version }} + version-file: luxonis_ml/__init__.py + project-name: LuxonisML + secrets: + WORKFLOW_SECRET: ${{ secrets.WORKFLOW_SECRET }} diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml new file mode 100644 index 00000000..99015379 --- /dev/null +++ b/.github/workflows/release-tag.yaml @@ -0,0 +1,17 @@ +name: Release Tag + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + release: + if: >- + github.event.pull_request.merged && + startsWith(github.event.pull_request.head.ref, 'release/') + uses: ./.github/workflows/reusable-release-tag.yaml + with: + version-file: luxonis_ml/__init__.py + secrets: + WORKFLOW_SECRET: ${{ secrets.WORKFLOW_SECRET }} diff --git a/.github/workflows/reusable-release-pr.yaml b/.github/workflows/reusable-release-pr.yaml new file mode 100644 index 00000000..febc23b1 --- /dev/null +++ b/.github/workflows/reusable-release-pr.yaml @@ -0,0 +1,125 @@ +name: Reusable Release PR + +# Shared by every Luxonis repository. Call it from a thin `Release PR` +# workflow, as `.github/workflows/release-pr.yaml` in this repository does. + +on: + workflow_call: + inputs: + version: + description: "major, minor, patch, or an explicit version such as 1.2.3" + type: string + required: true + version-file: + description: "File that holds `__version__`, such as pkg/__init__.py" + type: string + required: true + project-name: + description: "Name for the pull request title, such as LuxonisML" + type: string + required: true + secrets: + WORKFLOW_SECRET: + required: true + +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 gives the + # previous tag and the changes after it. + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Check out the version tool + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: luxonis/luxonis-ml + ref: main + path: .release-tools + sparse-checkout: tools/version.py + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Set the new version + id: version + env: + REQUEST: ${{ inputs.version }} + VERSION_FILE: ${{ inputs.version-file }} + run: | + version="$(python3 .release-tools/tools/version.py \ + --path "$VERSION_FILE" --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 }} + PROJECT: ${{ inputs.project-name }} + VERSION: ${{ steps.version.outputs.version }} + VERSION_FILE: ${{ inputs.version-file }} + 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" <- + A tag-name convention that every Luxonis release since 2024 keeps. + It does not mark the GitHub release as a prerelease, and it does not + change the PyPI version. Change both deliberately, not through this + input alone. + type: string + default: "-beta" + secrets: + WORKFLOW_SECRET: + required: true + +permissions: + contents: read + +jobs: + 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: Check out the version tool + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: luxonis/luxonis-ml + ref: main + path: .release-tools + sparse-checkout: tools/version.py + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Read the version + id: version + env: + VERSION_FILE: ${{ inputs.version-file }} + run: | + version="$(python3 .release-tools/tools/version.py --path "$VERSION_FILE")" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Create the release + env: + # The default token cannot start another workflow. The personal + # token makes the release event start the publish workflows. + GH_TOKEN: ${{ secrets.WORKFLOW_SECRET }} + TAG: v${{ steps.version.outputs.version }}${{ inputs.tag-suffix }} + TARGET: ${{ github.event.pull_request.merge_commit_sha }} + run: | + gh release create "$TAG" \ + --target "$TARGET" \ + --title "$TAG" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e2c1b4f..ec4b86bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -187,8 +188,58 @@ 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 --path luxonis_ml/__init__.py # report +python3 tools/version.py --path luxonis_ml/__init__.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. + +### Shared with the other repositories + +The release logic lives in two reusable workflows, and every Luxonis +repository calls them. Only the two thin caller workflows and the label +configuration are per repository. To adopt them elsewhere, add a +`Release PR` caller: + +```yaml +jobs: + release-pr: + uses: luxonis/luxonis-ml/.github/workflows/reusable-release-pr.yaml@main + with: + version: ${{ inputs.version }} + version-file: luxonis_train/__init__.py + project-name: LuxonisTrain + secrets: + WORKFLOW_SECRET: ${{ secrets.WORKFLOW_SECRET }} +``` + +and a `Release Tag` caller with the same `version-file`. The repository needs +the `WORKFLOW_SECRET` secret and a `release` label. The reusable workflows +check out `tools/version.py` from this repository, so the caller does not copy +it. + Package publishing and documentation deployment are handled by GitHub Actions: +- `reusable-release-pr.yaml` and `reusable-release-tag.yaml` hold the shared + release logic. +- `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, diff --git a/tools/version.py b/tools/version.py new file mode 100755 index 00000000..782bf058 --- /dev/null +++ b/tools/version.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Read or change a `__version__` declaration. + +The shared release workflows call this tool against the repository they +release, so the path to the file is always explicit. +""" + +import argparse +import ast +import re +import sys +from pathlib import Path + +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") + + +def main() -> None: + args = parse_args() + current = read_version(args.path) + + if args.new_version is None: + sys.stdout.write(f"{current}\n") + return + + new_version = resolve_version(current, args.new_version) + write_version(args.path, new_version) + sys.stdout.write(f"{new_version}\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Read or change a `__version__` declaration." + ) + parser.add_argument( + "--path", + type=Path, + required=True, + help="File that holds `__version__`, such as 'luxonis_ml/__init__.py'.", + ) + 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(path: Path) -> str: + return str(_version_node(path.read_text(encoding="utf-8"), path).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(path: Path, new_version: str) -> None: + text = path.read_text(encoding="utf-8") + node = _version_node(text, path) + 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}' + 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, path: Path) -> 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 {path}.") + + +if __name__ == "__main__": + main()