From b7a88eb4b02f18419649832759f5bcbd1a0fa85f Mon Sep 17 00:00:00 2001 From: Jari Turkia Date: Tue, 29 Sep 2026 21:57:25 +0300 Subject: [PATCH 1/3] ci: Tag and release every merge to main Merging a pull request to main tags the merge commit with the next version (patch by default; minor-version or major-version labels bump more) and starts Publish on that tag, which publishes to PyPI and creates the GitHub release. The first tag is v1.0.0. The version now comes from the tag through setuptools-scm, so pyproject.toml no longer holds one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + .github/workflows/publish.yml | 37 ++++++++++++----- .github/workflows/tag.yml | 75 +++++++++++++++++++++++++++++++++++ README.md | 21 ++++++++++ pyproject.toml | 10 ++++- 5 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/tag.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aa3e0e..c9921cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 # setuptools-scm needs the tags - uses: actions/setup-python@v6 with: python-version: "3.13" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a1b15b8..0296e10 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,10 +1,9 @@ name: Publish # Trusted Publishing: PyPI and TestPyPI trust this workflow, so no API token is stored. -# Run by hand (Actions → Publish → Run workflow) → TestPyPI. -# Push a tag v → PyPI. -# Deployment environments are not available in this repository. The "release tags" ruleset -# lets only repository admins create v* tags, so only they can release to PyPI. +# Started by Tag on a v* tag, after a merge to main → PyPI, then a GitHub release. +# Run by hand on a branch (Actions → Publish) → a .devN version on TestPyPI. +# A v* tag pushed by an admin also releases. The version comes from the tag (setuptools-scm). on: push: @@ -19,18 +18,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 # setuptools-scm needs the tags - uses: actions/setup-python@v6 with: python-version: "3.13" - - name: Tag matches the version in pyproject.toml + - run: python -m pip install --upgrade build twine + - run: python -m build + - name: Built version matches the tag if: startsWith(github.ref, 'refs/tags/v') run: | - version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') - if [ "v$version" != "$GITHUB_REF_NAME" ]; then - echo "::error::Tag $GITHUB_REF_NAME does not match version $version in pyproject.toml"; exit 1 + if [ ! -f "dist/mfiles_grpc-${GITHUB_REF_NAME#v}.tar.gz" ]; then + echo "::error::Tag $GITHUB_REF_NAME, but built $(ls dist)"; exit 1 fi - - run: python -m pip install --upgrade build twine - - run: python -m build - run: twine check --strict dist/* # The package must never carry the .proto itself, only the stubs generated from it. - name: No .proto in the package @@ -47,7 +47,7 @@ jobs: path: dist/ testpypi: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') needs: build runs-on: ubuntu-latest permissions: @@ -73,3 +73,18 @@ jobs: name: dist path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 + + release: + needs: pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v5 + with: + name: dist + path: dist/ + - run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes --title "$GITHUB_REF_NAME" + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..9f821f8 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,75 @@ +name: Tag + +# When a pull request is merged to main, tag the merge commit with the next version and start +# Publish on that tag. The pull request's labels choose the step: +# none → patch 1.2.3 → 1.2.4 +# minor-version → minor 1.2.3 → 1.3.0 +# major-version → major 1.2.3 → 2.0.0 +# The first tag is v1.0.0. +# +# pull_request_target runs this file as it is on main, with a token that can write. It never +# checks out or runs the pull request's code; it reads only the labels and the merge commit. +# A tag created with GITHUB_TOKEN starts no workflow, so Publish is started with +# workflow_dispatch, the one event GITHUB_TOKEN can trigger. The "release tags" ruleset lets +# GitHub Actions create v* tags. + +on: + pull_request_target: + types: [closed] + branches: [main] + +permissions: {} + +concurrency: + group: tag + cancel-in-progress: false + +jobs: + tag: + if: github.event.pull_request.merged + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.merge_commit_sha }} + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + steps: + - name: Next version + id: next + run: | + gh api --paginate "repos/$GH_REPO/git/matching-refs/tags/v" -q '.[] | .ref + " " + .object.sha' | + python3 -c ' + import json, os, re, sys + labels = set(json.loads(os.environ["LABELS"])) + versions = [] + for line in sys.stdin: + ref, sha = line.split() + if m := re.fullmatch(r"refs/tags/v(\d+)\.(\d+)\.(\d+)", ref): + if sha == os.environ["SHA"]: + sys.exit(f"::error::{ref} already tags {sha}") + versions.append(tuple(map(int, m.groups()))) + if not versions: + major, minor, patch = 1, 0, 0 + else: + major, minor, patch = max(versions) + if "major-version" in labels: + major, minor, patch = major + 1, 0, 0 + elif "minor-version" in labels: + minor, patch = minor + 1, 0 + else: + patch += 1 + print(f"tag=v{major}.{minor}.{patch}") + ' >> "$GITHUB_OUTPUT" + - name: Create the tag + run: gh api "repos/$GH_REPO/git/refs" -f ref="refs/tags/$TAG" -f sha="$SHA" --silent + env: + TAG: ${{ steps.next.outputs.tag }} + - name: Start Publish + run: | + gh workflow run publish.yml --ref "$TAG" + echo "Tagged $SHA as $TAG and started Publish" >> "$GITHUB_STEP_SUMMARY" + env: + TAG: ${{ steps.next.outputs.tag }} diff --git a/README.md b/README.md index 8a092dd..a36a4ee 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,27 @@ flake8 --max-line-length 120 --extend-exclude src/mfiles_grpc/_generated src tes Offline; they need no vault. `scripts/live_object_test.py` is the live check. +## Releasing + +Every pull request merged to `main` is released; nothing is tagged or versioned by hand. + +1. The Tag workflow tags the merge commit with the next version. The pull request's labels + choose the step: + + | Label | Step | Example | + | --- | --- | --- | + | none | patch | 1.2.3 → 1.2.4 | + | `minor-version` | minor | 1.2.3 → 1.3.0 | + | `major-version` | major | 1.2.3 → 2.0.0 | + + The first tag is `v1.0.0`. +2. It then starts the Publish workflow on that tag, which builds the package, publishes it to + PyPI and creates the GitHub release. + +The package version comes from the tag ([setuptools-scm](https://setuptools-scm.readthedocs.io/)), +so `pyproject.toml` holds none. Running Publish by hand on a branch publishes a `.devN` version +to TestPyPI. + ## License MIT; see [LICENSE](https://github.com/M-Files/mfiles-grpc-python/blob/main/LICENSE). diff --git a/pyproject.toml b/pyproject.toml index b17c7ed..6528ad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=77.0"] +requires = ["setuptools>=77.0", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "mfiles-grpc" -version = "0.1.0" +dynamic = ["version"] description = "Python client for the M-Files gRPC API" readme = "README.md" license = "MIT" @@ -55,5 +55,11 @@ mfiles-grpc = "mfiles_grpc.__main__:main" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools_scm] +# The version is the latest v* tag, which the Tag workflow creates on every merge to main. +# Between tags it is a .devN version; the local "+g" part is dropped because PyPI +# and TestPyPI refuse it. +local_scheme = "no-local-version" + [tool.pytest.ini_options] testpaths = ["tests"] From f56fee8d561f6453ff812d89129f0e5c59c5aa2b Mon Sep 17 00:00:00 2001 From: Jari Turkia Date: Tue, 29 Sep 2026 22:01:50 +0300 Subject: [PATCH 2/3] ci: Publish when a GitHub release is published Tag only tags the merge commit. Publishing a GitHub release on a v* tag starts Publish, which publishes to PyPI and attaches the wheel and sdist to that release. Pushing a tag no longer publishes, and running Publish by hand on a tag no longer reaches PyPI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 19 ++++++++++--------- .github/workflows/tag.yml | 19 +++++++------------ README.md | 7 ++++--- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0296e10..5efd979 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,13 +1,14 @@ name: Publish # Trusted Publishing: PyPI and TestPyPI trust this workflow, so no API token is stored. -# Started by Tag on a v* tag, after a merge to main → PyPI, then a GitHub release. -# Run by hand on a branch (Actions → Publish) → a .devN version on TestPyPI. -# A v* tag pushed by an admin also releases. The version comes from the tag (setuptools-scm). +# A GitHub release published on a v* tag → PyPI, and the files are attached to the release. +# Run by hand on a branch (Actions → Publish) → a .devN version on TestPyPI. +# The Tag workflow creates the v* tags on merges to main. The version comes from the tag +# (setuptools-scm). on: - push: - tags: ["v*"] + release: + types: [published] workflow_dispatch: permissions: @@ -47,7 +48,7 @@ jobs: path: dist/ testpypi: - if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/') needs: build runs-on: ubuntu-latest permissions: @@ -62,7 +63,7 @@ jobs: repository-url: https://test.pypi.org/legacy/ pypi: - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') needs: build runs-on: ubuntu-latest permissions: @@ -74,7 +75,7 @@ jobs: path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 - release: + attach: needs: pypi runs-on: ubuntu-latest permissions: @@ -84,7 +85,7 @@ jobs: with: name: dist path: dist/ - - run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes --title "$GITHUB_REF_NAME" + - run: gh release upload "$GITHUB_REF_NAME" dist/* env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 9f821f8..ae79fd8 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -1,7 +1,7 @@ name: Tag -# When a pull request is merged to main, tag the merge commit with the next version and start -# Publish on that tag. The pull request's labels choose the step: +# When a pull request is merged to main, tag the merge commit with the next version. The pull +# request's labels choose the step: # none → patch 1.2.3 → 1.2.4 # minor-version → minor 1.2.3 → 1.3.0 # major-version → major 1.2.3 → 2.0.0 @@ -9,9 +9,9 @@ name: Tag # # pull_request_target runs this file as it is on main, with a token that can write. It never # checks out or runs the pull request's code; it reads only the labels and the merge commit. -# A tag created with GITHUB_TOKEN starts no workflow, so Publish is started with -# workflow_dispatch, the one event GITHUB_TOKEN can trigger. The "release tags" ruleset lets -# GitHub Actions create v* tags. +# The "release tags" ruleset lets GitHub Actions create v* tags. +# +# Tagging publishes nothing: creating a GitHub release on the tag starts Publish. on: pull_request_target: @@ -30,7 +30,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - actions: write env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -64,12 +63,8 @@ jobs: print(f"tag=v{major}.{minor}.{patch}") ' >> "$GITHUB_OUTPUT" - name: Create the tag - run: gh api "repos/$GH_REPO/git/refs" -f ref="refs/tags/$TAG" -f sha="$SHA" --silent - env: - TAG: ${{ steps.next.outputs.tag }} - - name: Start Publish run: | - gh workflow run publish.yml --ref "$TAG" - echo "Tagged $SHA as $TAG and started Publish" >> "$GITHUB_STEP_SUMMARY" + gh api "repos/$GH_REPO/git/refs" -f ref="refs/tags/$TAG" -f sha="$SHA" --silent + echo "Tagged $SHA as $TAG" >> "$GITHUB_STEP_SUMMARY" env: TAG: ${{ steps.next.outputs.tag }} diff --git a/README.md b/README.md index a36a4ee..6ecac48 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ Offline; they need no vault. `scripts/live_object_test.py` is the live check. ## Releasing -Every pull request merged to `main` is released; nothing is tagged or versioned by hand. +Versions are never set by hand: every pull request merged to `main` gets the next one. 1. The Tag workflow tags the merge commit with the next version. The pull request's labels choose the step: @@ -297,8 +297,9 @@ Every pull request merged to `main` is released; nothing is tagged or versioned | `major-version` | major | 1.2.3 → 2.0.0 | The first tag is `v1.0.0`. -2. It then starts the Publish workflow on that tag, which builds the package, publishes it to - PyPI and creates the GitHub release. +2. To release, create a GitHub release on that tag (Releases → Draft a new release). Publishing + it starts the Publish workflow, which builds the package, publishes it to PyPI and attaches + the wheel and the sdist to the release. A tag without a release is not published. The package version comes from the tag ([setuptools-scm](https://setuptools-scm.readthedocs.io/)), so `pyproject.toml` holds none. Running Publish by hand on a branch publishes a `.devN` version From 88b2fb18aba618e5dc0ec4431f8f15547f96850a Mon Sep 17 00:00:00 2001 From: Jari Turkia Date: Tue, 29 Sep 2026 22:10:46 +0300 Subject: [PATCH 3/3] ci: Explain why the ruleset lets anyone create v* tags Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tag.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index ae79fd8..54d0ade 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -9,7 +9,8 @@ name: Tag # # pull_request_target runs this file as it is on main, with a token that can write. It never # checks out or runs the pull request's code; it reads only the labels and the merge commit. -# The "release tags" ruleset lets GitHub Actions create v* tags. +# The "release tags" ruleset stops v* tags from being moved or deleted, but not created: +# GITHUB_TOKEN cannot be given a ruleset bypass. # # Tagging publishes nothing: creating a GitHub release on the tag starts Publish.