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..5efd979 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,14 +1,14 @@ 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. +# 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: @@ -19,18 +19,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 +48,7 @@ jobs: path: dist/ testpypi: - if: github.event_name == 'workflow_dispatch' + 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: @@ -73,3 +74,18 @@ jobs: name: dist path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 + + attach: + needs: pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v5 + with: + name: dist + path: dist/ + - 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 new file mode 100644 index 0000000..54d0ade --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,71 @@ +name: Tag + +# 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 +# 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. +# 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. + +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 + 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 + echo "Tagged $SHA as $TAG" >> "$GITHUB_STEP_SUMMARY" + env: + TAG: ${{ steps.next.outputs.tag }} diff --git a/README.md b/README.md index 8a092dd..6ecac48 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,28 @@ 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 + +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: + + | 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. 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 +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"]