From d46ca4376028ca27d63ba069b7fffcedf1f965d0 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Fri, 14 Aug 2026 13:00:04 +0200 Subject: [PATCH 1/6] Automate the release with two workflows `Release PR` changes the version and opens the release pull request. The merge of that pull request starts `Release Tag`. That workflow tags the merge commit and creates the GitHub release with generated notes. The release event then starts the PyPI upload and the docs deployment, as before. Both workflows read the version with `tools/version.py`. The same tool changes the version on a local machine. The workflows use `WORKFLOW_SECRET`. The default token does not start CI on a bot pull request, and it cannot start the publish workflow from a release event. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-pr.yaml | 99 ++++++++++++++++++++++++++ .github/workflows/release-tag.yaml | 44 ++++++++++++ CONTRIBUTING.md | 22 ++++++ tools/version.py | 109 +++++++++++++++++++++++++++++ 4 files changed, 274 insertions(+) create mode 100644 .github/workflows/release-pr.yaml create mode 100644 .github/workflows/release-tag.yaml create mode 100755 tools/version.py diff --git a/.github/workflows/release-pr.yaml b/.github/workflows/release-pr.yaml new file mode 100644 index 00000000..8b5afde6 --- /dev/null +++ b/.github/workflows/release-pr.yaml @@ -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" <- + 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e2c1b4f..42ab9d1e 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,29 @@ 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 +``` + 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, diff --git a/tools/version.py b/tools/version.py new file mode 100755 index 00000000..e48dae9f --- /dev/null +++ b/tools/version.py @@ -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() From d7b7c139ed8b6d50045509169b56c3f0ae549506 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Fri, 14 Aug 2026 13:00:18 +0200 Subject: [PATCH 2/6] Group the generated release notes by label `.github/release.yaml` sorts the pull requests into New features, Bug fixes, Documentation, Maintenance, and Other changes. It also drops the version bump pull request from its own notes. The labeler gives every `release/*` pull request the `release` label, which drives that exclusion. Co-Authored-By: Claude Opus 5 (1M context) --- .github/labeler.yaml | 3 +++ .github/release.yaml | 25 +++++++++++++++++++++++++ CONTRIBUTING.md | 3 +++ 3 files changed, 31 insertions(+) create mode 100644 .github/release.yaml 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/CONTRIBUTING.md b/CONTRIBUTING.md index 42ab9d1e..b91f6d16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -207,6 +207,9 @@ 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. From c05886bca5398cc237e5a20cb5aed64d55f492b4 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Fri, 14 Aug 2026 13:32:48 +0200 Subject: [PATCH 3/6] Describe the tag suffix accurately The comment implied that `-beta` marks a prerelease. It does not. Every release since v0.3.0 carries the suffix, and all 25 of them have `prerelease=false`. PyPI holds only plain `X.Y.Z` versions. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-tag.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml index 378a1e06..1bfddf43 100644 --- a/.github/workflows/release-tag.yaml +++ b/.github/workflows/release-tag.yaml @@ -9,7 +9,9 @@ permissions: contents: read env: - # Every tag after v0.3.0 carries this suffix. Empty it for a stable release. + # 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: From 960768d1e60fb819336dc19ea1ec07c1e0327e3e Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Fri, 14 Aug 2026 13:45:58 +0200 Subject: [PATCH 4/6] Make the release workflows reusable Every Luxonis repository runs the same release process. The logic now lives in `reusable-release-pr.yaml` and `reusable-release-tag.yaml`. They take the version file, the project name, and the tag suffix as inputs. Each repository keeps only a thin caller workflow and its label configuration. `tools/version.py` now needs a `--path` argument, because the reusable workflows run it against the repository that they release. The workflows check the tool out from this repository, so a caller does not copy it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-pr.yaml | 93 +-------------- .github/workflows/release-tag.yaml | 38 +----- .github/workflows/reusable-release-pr.yaml | 125 ++++++++++++++++++++ .github/workflows/reusable-release-tag.yaml | 67 +++++++++++ CONTRIBUTING.md | 29 ++++- tools/version.py | 34 +++--- 6 files changed, 249 insertions(+), 137 deletions(-) create mode 100644 .github/workflows/reusable-release-pr.yaml create mode 100644 .github/workflows/reusable-release-tag.yaml diff --git a/.github/workflows/release-pr.yaml b/.github/workflows/release-pr.yaml index 8b5afde6..406fe6cb 100644 --- a/.github/workflows/release-pr.yaml +++ b/.github/workflows/release-pr.yaml @@ -8,92 +8,11 @@ on: 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" <- 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 + uses: ./.github/workflows/reusable-release-tag.yaml + with: + version-file: luxonis_ml/__init__.py + secrets: inherit 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 b91f6d16..c2c64d0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,15 +203,40 @@ request it opens. 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 +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: inherit +``` + +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 diff --git a/tools/version.py b/tools/version.py index e48dae9f..782bf058 100755 --- a/tools/version.py +++ b/tools/version.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -"""Read or change the version in ``luxonis_ml/__init__.py``. +"""Read or change a `__version__` declaration. -Run it from the repository root. +The shared release workflows call this tool against the repository they +release, so the path to the file is always explicit. """ import argparse @@ -10,26 +11,31 @@ 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() + 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(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 the LuxonisML version." + 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", @@ -44,8 +50,8 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def read_version() -> str: - return str(_version_node(INIT_PATH.read_text(encoding="utf-8")).value) +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: @@ -77,22 +83,22 @@ def parse_version(version: str) -> tuple[int, int, int]: return major, minor, patch -def write_version(new_version: str) -> None: - text = INIT_PATH.read_text(encoding="utf-8") - node = _version_node(text) +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}' - INIT_PATH.write_text("".join(lines), encoding="utf-8") + 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: +def _version_node(text: str, path: Path) -> ast.Constant: for node in ast.parse(text).body: if ( isinstance(node, ast.AnnAssign) @@ -102,7 +108,7 @@ def _version_node(text: str) -> ast.Constant: and isinstance(node.value.value, str) ): return node.value - raise SystemExit(f"Found no `__version__` string in {INIT_PATH}.") + raise SystemExit(f"Found no `__version__` string in {path}.") if __name__ == "__main__": From 503f7fd3776caa70d2965048b54087b25118a46c Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Fri, 14 Aug 2026 14:02:11 +0200 Subject: [PATCH 5/6] Pass only the secret that the release workflows need `secrets: inherit` gives a called workflow every secret in the repository, which includes the PyPI token and the cloud credentials. The callers now pass `WORKFLOW_SECRET` alone, which is the only secret that the reusable workflows declare. Semgrep reported this on the pull request. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-pr.yaml | 3 ++- .github/workflows/release-tag.yaml | 3 ++- CONTRIBUTING.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-pr.yaml b/.github/workflows/release-pr.yaml index 406fe6cb..a18910a4 100644 --- a/.github/workflows/release-pr.yaml +++ b/.github/workflows/release-pr.yaml @@ -15,4 +15,5 @@ jobs: version: ${{ inputs.version }} version-file: luxonis_ml/__init__.py project-name: LuxonisML - secrets: inherit + secrets: + WORKFLOW_SECRET: ${{ secrets.WORKFLOW_SECRET }} diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml index d3b7a935..99015379 100644 --- a/.github/workflows/release-tag.yaml +++ b/.github/workflows/release-tag.yaml @@ -13,4 +13,5 @@ jobs: uses: ./.github/workflows/reusable-release-tag.yaml with: version-file: luxonis_ml/__init__.py - secrets: inherit + secrets: + WORKFLOW_SECRET: ${{ secrets.WORKFLOW_SECRET }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2c64d0e..ec4b86bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,7 +225,8 @@ jobs: version: ${{ inputs.version }} version-file: luxonis_train/__init__.py project-name: LuxonisTrain - secrets: inherit + secrets: + WORKFLOW_SECRET: ${{ secrets.WORKFLOW_SECRET }} ``` and a `Release Tag` caller with the same `version-file`. The repository needs From 28a76d39152319ae964acc1b481be1fede905c42 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Fri, 14 Aug 2026 14:40:53 +0200 Subject: [PATCH 6/6] Hold the PyPI upload for an approval The `pypi` environment carries the ML-Reviewers team as a required reviewer. The upload job now waits for one approval, whatever started it: a release event, a manual dispatch, or a workflow that someone changed on a branch. Move `PYPI_API_TOKEN` into the environment to complete the control. A job cannot then read the token at all until a reviewer approves. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/python-publish.yml | 3 +++ 1 file changed, 3 insertions(+) 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