From 056171a449c6622cc30fb7be888af73a851d04c9 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 13 Aug 2026 08:28:01 -0500 Subject: [PATCH 1/7] PYTHON-5956 Add python/setup action for shared uv+just CI setup Python driver repos each repeat the same block of steps to get uv, just, and dependencies in place before a CI job can run. This action does that once so they can share it. Python comes from setup-python and uv is pointed at that interpreter through UV_PYTHON, rather than letting uv download a managed one: the runner images already ship it. `just install` is optional two ways over. A job that needs no project dependencies sets run-install to false, and a project with no justfile or no install recipe skips the step instead of failing, so the same defaults work for consumers that only want the tools on PATH. Recipe detection matches whole names. Substring matching would fire on install-deps, preinstall, and uninstall, and `grep -w` is no help there because it counts `-` as a word boundary. The repo's own Python test job now uses the action to get uv and just, which exercises it end to end on every run. --- .github/workflows/ci.yml | 10 ++- README.md | 40 +++++++++++- python/setup/action.yml | 63 +++++++++++++++++++ python/setup/run_install.sh | 24 +++++++ python/setup/test_run_install.sh | 103 +++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 python/setup/action.yml create mode 100755 python/setup/run_install.sh create mode 100755 python/setup/test_run_install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a59df7..83b3d56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,14 @@ jobs: with: persist-credentials: false - - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + # The tests below need uv and just, and the python/setup action installs + # both. Using it here also exercises it end to end on every run, including + # its skip path: this repo has no justfile, so `just install` is skipped. + - name: Install Python tooling + uses: $/python/setup + + - name: Run setup tests + run: bash python/setup/test_run_install.sh - name: Run uv lock update tests run: | diff --git a/README.md b/README.md index bd80688..79f36ce 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,45 @@ jobs: ## Python Helper Scripts -These scripts are opinionated helper scripts for Python releases. +These are opinionated helper actions for Python CI and releases. + +### Setup + +This action installs Python, uv, and just, and runs the project's `just install` +recipe. It replaces the block of setup steps that Python driver repos otherwise +repeat in every CI job. + +```yaml +- uses: mongodb-labs/drivers-github-tools/python/setup@v3 + with: + python-version: "3.10" +``` + +Python comes from `actions/setup-python`, because the runner images already ship +several versions and installing from them is faster than having uv download a +managed interpreter. uv is then pointed at that exact interpreter through +`UV_PYTHON`, so it does not pick a different one based on the project's +`pyproject.toml` or `.python-version`. + +Set `run-install: "false"` for a job that does not need project dependencies: + +```yaml +- uses: mongodb-labs/drivers-github-tools/python/setup@v3 + with: + python-version: ${{ matrix.python-version }} + run-install: "false" +``` + +Leaving `run-install` at its default is also safe for a project with no justfile, +or one whose justfile has no `install` recipe: the step reports the skip and +succeeds. A recipe that exists and fails does fail the job. + +`exclude-newer` sets `UV_EXCLUDE_NEWER`, which holds back packages published +after the given point and defaults to `7 days`. It takes a date (`2026-01-01`), +an RFC 3339 timestamp, or a duration (`7 days`, `P7D`). Set it to an empty string +to leave the variable unset. + +`enable-cache` defaults to `true` and is passed through to `astral-sh/setup-uv`. ### Pre-Publish diff --git a/python/setup/action.yml b/python/setup/action.yml new file mode 100644 index 0000000..5221870 --- /dev/null +++ b/python/setup/action.yml @@ -0,0 +1,63 @@ +name: Python Setup +description: Installs Python, uv, and just, and optionally runs the project's `just install` recipe +inputs: + python-version: + description: Python version for setup-python to install, and the interpreter uv will use + default: "3.10" + enable-cache: + description: Cache the uv download cache between runs + default: "true" + exclude-newer: + description: >- + UV_EXCLUDE_NEWER value, which holds back packages published after it: a + date (2026-01-01), an RFC 3339 timestamp, or a duration ('7 days', 'P7D'). + Set to an empty string to leave it unset. + default: "7 days" + run-install: + description: >- + Run the project's `just install` recipe. Set to 'false' for jobs that do + not need project dependencies. When 'true' but the project has no justfile + or no `install` recipe, the step is skipped rather than failing. + default: "true" + +runs: + using: composite + steps: + # The runner images already ship several Python versions, so installing from + # them is faster than having uv download a managed interpreter. uv is pointed + # at this one below rather than choosing its own. + - name: Install Python + id: setup-python + uses: actions/setup-python@v7 + with: + python-version: ${{ inputs.python-version }} + + - name: Configure uv + shell: bash + env: + # Inputs go through env rather than into the script body, so a value + # containing shell metacharacters cannot alter the commands below. + PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + run: | + # An absolute interpreter path makes the choice exact: uv uses this + # interpreter and never falls back to downloading one, whatever the + # consuming project's pyproject.toml or .python-version asks for. + echo "UV_PYTHON=$PYTHON_PATH" >> "$GITHUB_ENV" + if [ -n "$EXCLUDE_NEWER" ]; then + echo "UV_EXCLUDE_NEWER=$EXCLUDE_NEWER" >> "$GITHUB_ENV" + fi + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: ${{ inputs.enable-cache }} + + - name: Install just + shell: bash + run: uv tool install rust-just + + - name: Run just install + if: inputs.run-install == 'true' + shell: bash + run: ${{ github.action_path }}/run_install.sh diff --git a/python/setup/run_install.sh b/python/setup/run_install.sh new file mode 100755 index 0000000..adc740a --- /dev/null +++ b/python/setup/run_install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Run `just install` when the project defines that recipe, and skip cleanly when +# it does not. Consumers that only want uv and just on PATH — or that have no +# justfile at all — get a no-op rather than a failure, so the same action serves +# both without the caller having to know which case it is in. +set -euo pipefail + +# `just --summary` prints the recipe names on one space-separated line and exits +# non-zero only when there is no justfile to read. A justfile with no recipes +# exits 0 with empty output, so it falls through to the recipe check below. +if ! SUMMARY=$(just --summary 2>/dev/null); then + echo "Skipping 'just install': no justfile found." + exit 0 +fi + +# Split to one name per line and match whole lines. Substring matching would fire +# on `install-deps`, `preinstall`, and `uninstall` — and `grep -w` is no help, +# because it counts `-` as a word boundary and so still matches `install-deps`. +if ! echo "$SUMMARY" | tr ' ' '\n' | grep -qx 'install'; then + echo "Skipping 'just install': no 'install' recipe in the justfile." + exit 0 +fi + +just install diff --git a/python/setup/test_run_install.sh b/python/setup/test_run_install.sh new file mode 100755 index 0000000..93099e0 --- /dev/null +++ b/python/setup/test_run_install.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/run_install.sh" +FAIL=0 +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +check() { + local desc="$1" + local expected="$2" + local actual="$3" + if [ "$actual" = "$expected" ]; then + echo "OK: $desc" + else + echo "FAIL: $desc" + echo " expected: $expected" + echo " actual: $actual" + FAIL=1 + fi +} + +check_contains() { + local desc="$1" + local needle="$2" + local haystack="$3" + if echo "$haystack" | grep -qF -- "$needle"; then + echo "OK: $desc" + else + echo "FAIL: $desc" + echo " expected to find: $needle" + echo " in: $haystack" + FAIL=1 + fi +} + +# Each case runs in its own directory. `just` walks up to find a justfile, so the +# "no justfile" case needs a tree with none above it: TMPDIR is under the system +# temp dir, not the repo. +run_script() { + local case_name="$1" + local justfile_body="${2-}" + WORKDIR="$TMPDIR/$case_name" + mkdir -p "$WORKDIR" + if [ -n "$justfile_body" ]; then + printf '%s' "$justfile_body" > "$WORKDIR/justfile" + fi + STATUS=0 + OUTPUT=$(cd "$WORKDIR" && bash "$SCRIPT" 2>&1) || STATUS=$? +} + +ran_marker() { [ -f "$WORKDIR/ran" ] && echo "ran" || echo "did not run"; } + +# The whole point of the action's optional install step: a project that defines +# `install` gets it run. +run_script "has-install" 'install: + @touch ran +' +check "install recipe: exit status is 0" "0" "$STATUS" +check "install recipe: it is run" "ran" "$(ran_marker)" + +# A recipe named `install-deps` contains "install" as a prefix. `grep -w` treats +# `-` as a word boundary and would match it, running the wrong recipe name (or +# failing outright). Matching must be on the whole recipe name. +run_script "install-prefix" 'install-deps: + @touch ran +' +check "install-deps only: exit status is 0" "0" "$STATUS" +check "install-deps only: nothing is run" "did not run" "$(ran_marker)" +check_contains "install-deps only: the skip is reported" "no 'install' recipe" "$OUTPUT" + +# The same substring trap from the other side: `uninstall` and `preinstall` both +# contain "install" and must not be mistaken for it. +run_script "install-substrings" 'uninstall: + @touch ran + +preinstall: + @touch ran +' +check "uninstall/preinstall only: exit status is 0" "0" "$STATUS" +check "uninstall/preinstall only: nothing is run" "did not run" "$(ran_marker)" + +# A repo with no justfile at all must be a clean skip, not a failure: this is the +# case for consumers that use the action purely to get uv and just on PATH. +run_script "no-justfile" +check "no justfile: exit status is 0" "0" "$STATUS" +check_contains "no justfile: the skip is reported" "no justfile" "$OUTPUT" + +# A justfile with recipes but no `install` is a skip, and one with no recipes at +# all must not trip the "no justfile" path either. +run_script "no-recipes" '# a justfile with only a comment +' +check "justfile with no recipes: exit status is 0" "0" "$STATUS" +check_contains "justfile with no recipes: the skip is reported" "no 'install' recipe" "$OUTPUT" + +# A failing install must fail the job. Swallowing it would let CI proceed with +# missing dependencies and report a confusing downstream error instead. +run_script "failing-install" 'install: + @exit 3 +' +check "failing install recipe: the failure propagates" "3" "$STATUS" + +exit $FAIL From 352a3dd9b414e12ba2b7b3a677c82009ea3570dd Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 13 Aug 2026 08:48:17 -0500 Subject: [PATCH 2/7] Add allow-prereleases input setup-python fails on a version with no stable release yet, where uv would have downloaded the prerelease itself. mongo-python-driver's build matrix tests against a beta Python, so it needs the fallback available. --- README.md | 4 ++++ python/setup/action.yml | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 79f36ce..a259f0b 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,10 @@ to leave the variable unset. `enable-cache` defaults to `true` and is passed through to `astral-sh/setup-uv`. +Set `allow-prereleases: "true"` to test against a Python version that has no stable +release yet, such as a version still in beta. Without it, `setup-python` fails +rather than falling back to a prerelease. + ### Pre-Publish Bump the version and create a new tag. Verify the tag. diff --git a/python/setup/action.yml b/python/setup/action.yml index 5221870..9daf4bf 100644 --- a/python/setup/action.yml +++ b/python/setup/action.yml @@ -4,6 +4,11 @@ inputs: python-version: description: Python version for setup-python to install, and the interpreter uv will use default: "3.10" + allow-prereleases: + description: >- + Let setup-python fall back to a prerelease when python-version has no + stable release yet. Needed to test against a Python still in beta. + default: "false" enable-cache: description: Cache the uv download cache between runs default: "true" @@ -31,6 +36,7 @@ runs: uses: actions/setup-python@v7 with: python-version: ${{ inputs.python-version }} + allow-prereleases: ${{ inputs.allow-prereleases }} - name: Configure uv shell: bash From b4f2bf29dec7a1ff0ea1b0b4b7837feb1d966309 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 13 Aug 2026 08:52:10 -0500 Subject: [PATCH 3/7] Leave exclude-newer unset by default The design copied a 7 day cooldown from mongo-python-driver's local set-uv-exclude-newer action, but PYTHON-5980 deleted that action and committed uv.lock instead. A repo with a lock file has already pinned its resolution, and UV_EXCLUDE_NEWER set here would override it. Keep the input for a repo that wants a cooldown and has no lock file, but do not impose one. --- README.md | 10 ++++++---- python/setup/action.yml | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a259f0b..2afc62e 100644 --- a/README.md +++ b/README.md @@ -457,10 +457,12 @@ Leaving `run-install` at its default is also safe for a project with no justfile or one whose justfile has no `install` recipe: the step reports the skip and succeeds. A recipe that exists and fails does fail the job. -`exclude-newer` sets `UV_EXCLUDE_NEWER`, which holds back packages published -after the given point and defaults to `7 days`. It takes a date (`2026-01-01`), -an RFC 3339 timestamp, or a duration (`7 days`, `P7D`). Set it to an empty string -to leave the variable unset. +`exclude-newer` sets `UV_EXCLUDE_NEWER`, which holds back packages published after +the given point. It takes a date (`2026-01-01`), an RFC 3339 timestamp, or a +duration (`7 days`, `P7D`). It is empty by default, because a repo that commits +`uv.lock` already pins resolution and an environment variable set here would +override whatever the repo configured for itself. Set it only for a repo that +wants a cooldown and has no lock file. `enable-cache` defaults to `true` and is passed through to `astral-sh/setup-uv`. diff --git a/python/setup/action.yml b/python/setup/action.yml index 9daf4bf..bf3822c 100644 --- a/python/setup/action.yml +++ b/python/setup/action.yml @@ -16,8 +16,9 @@ inputs: description: >- UV_EXCLUDE_NEWER value, which holds back packages published after it: a date (2026-01-01), an RFC 3339 timestamp, or a duration ('7 days', 'P7D'). - Set to an empty string to leave it unset. - default: "7 days" + Empty by default, which leaves resolution to the consuming repo's own + configuration — a committed uv.lock, or exclude-newer in its pyproject.toml. + default: "" run-install: description: >- Run the project's `just install` recipe. Set to 'false' for jobs that do From 06e75028a6074ee19bff9c1be52f76a7727845a8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 13 Aug 2026 09:00:11 -0500 Subject: [PATCH 4/7] Silence the github-env audit on the uv configuration step Code scanning treats the two GITHUB_ENV writes as new alerts and fails the check. Exporting to the caller's later steps is what the step is for, and a composite action has no other mechanism, so the audit has nothing actionable to report. --- python/setup/action.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/setup/action.yml b/python/setup/action.yml index bf3822c..15656de 100644 --- a/python/setup/action.yml +++ b/python/setup/action.yml @@ -46,7 +46,12 @@ runs: # containing shell metacharacters cannot alter the commands below. PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} EXCLUDE_NEWER: ${{ inputs.exclude-newer }} - run: | + # Exporting to the caller's later steps is the point of this step, and + # GITHUB_ENV is the only way a composite action can do it, so the + # github-env audit has nothing actionable to say here. The values come + # from setup-python and this action's own inputs, not from a caller's + # untrusted content. + run: | # zizmor: ignore[github-env] # An absolute interpreter path makes the choice exact: uv uses this # interpreter and never falls back to downloading one, whatever the # consuming project's pyproject.toml or .python-version asks for. From 3f6f4c4af657645b81f193438b6c1cf68f12578e Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 13 Aug 2026 10:12:54 -0500 Subject: [PATCH 5/7] Default allow-prereleases to true uv resolves a prerelease Python on its own when no stable release exists. A repo moving to this action would otherwise lose that and fail instead, so the default should match the behaviour being replaced rather than the stricter setup-python default. --- README.md | 8 +++++--- python/setup/action.yml | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2afc62e..f3b4971 100644 --- a/README.md +++ b/README.md @@ -466,9 +466,11 @@ wants a cooldown and has no lock file. `enable-cache` defaults to `true` and is passed through to `astral-sh/setup-uv`. -Set `allow-prereleases: "true"` to test against a Python version that has no stable -release yet, such as a version still in beta. Without it, `setup-python` fails -rather than falling back to a prerelease. +`allow-prereleases` defaults to `true`, so asking for a Python version with no +stable release yet gets the prerelease instead of failing. uv resolves such a +version on its own, so leaving this on keeps that behaviour for a repo moving to +this action. Set it to `"false"` for a job that should fail rather than quietly +test against a beta. ### Pre-Publish diff --git a/python/setup/action.yml b/python/setup/action.yml index 15656de..5b7fd6f 100644 --- a/python/setup/action.yml +++ b/python/setup/action.yml @@ -7,8 +7,10 @@ inputs: allow-prereleases: description: >- Let setup-python fall back to a prerelease when python-version has no - stable release yet. Needed to test against a Python still in beta. - default: "false" + stable release yet, so asking for a Python still in beta gets that beta + instead of failing. On by default, because uv resolves such a version on + its own and a consumer moving to this action should not lose that. + default: "true" enable-cache: description: Cache the uv download cache between runs default: "true" From f513d3cb3a1e6acf40e94ca82ddc336c13d49dbb Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 13 Aug 2026 10:32:28 -0500 Subject: [PATCH 6/7] Drop the exclude-newer input and split the Python docs out Setting UV_EXCLUDE_NEWER from here fought with the consuming repo. A repo that commits uv.lock records exclude-newer in the lock, so exporting a different value made `uv lock --check` fail: mongo-python-driver's static job would have broken. Resolution policy belongs to the repo, in its pyproject.toml, uv.toml, or lock file, so the action no longer touches it. Python action docs move to python/README.md and python-labs/README.md, with the top-level README linking to them. --- README.md | 250 +--------------------------------------- python-labs/README.md | 74 ++++++++++++ python/README.md | 169 +++++++++++++++++++++++++++ python/setup/action.yml | 22 +--- 4 files changed, 254 insertions(+), 261 deletions(-) create mode 100644 python-labs/README.md create mode 100644 python/README.md diff --git a/README.md b/README.md index f3b4971..d51913f 100644 --- a/README.md +++ b/README.md @@ -422,249 +422,11 @@ jobs: token: ${{ github.token }} ``` -## Python Helper Scripts +## Python Actions -These are opinionated helper actions for Python CI and releases. +Python helper actions have their own READMEs: -### Setup - -This action installs Python, uv, and just, and runs the project's `just install` -recipe. It replaces the block of setup steps that Python driver repos otherwise -repeat in every CI job. - -```yaml -- uses: mongodb-labs/drivers-github-tools/python/setup@v3 - with: - python-version: "3.10" -``` - -Python comes from `actions/setup-python`, because the runner images already ship -several versions and installing from them is faster than having uv download a -managed interpreter. uv is then pointed at that exact interpreter through -`UV_PYTHON`, so it does not pick a different one based on the project's -`pyproject.toml` or `.python-version`. - -Set `run-install: "false"` for a job that does not need project dependencies: - -```yaml -- uses: mongodb-labs/drivers-github-tools/python/setup@v3 - with: - python-version: ${{ matrix.python-version }} - run-install: "false" -``` - -Leaving `run-install` at its default is also safe for a project with no justfile, -or one whose justfile has no `install` recipe: the step reports the skip and -succeeds. A recipe that exists and fails does fail the job. - -`exclude-newer` sets `UV_EXCLUDE_NEWER`, which holds back packages published after -the given point. It takes a date (`2026-01-01`), an RFC 3339 timestamp, or a -duration (`7 days`, `P7D`). It is empty by default, because a repo that commits -`uv.lock` already pins resolution and an environment variable set here would -override whatever the repo configured for itself. Set it only for a repo that -wants a cooldown and has no lock file. - -`enable-cache` defaults to `true` and is passed through to `astral-sh/setup-uv`. - -`allow-prereleases` defaults to `true`, so asking for a Python version with no -stable release yet gets the prerelease instead of failing. uv resolves such a -version on its own, so leaving this on keeps that behaviour for a repo moving to -this action. Set it to `"false"` for a job that should fail rather than quietly -test against a beta. - -### Pre-Publish - -Bump the version and create a new tag. Verify the tag. -Push the commit and tag to the source branch unless `dry_run` is set. - -```yaml -- name: Setup - uses: mongodb-labs/drivers-github-tools/setup@v3 - with: - ... - -- uses: mongodb-labs/drivers-github-tools/python/pre-publishv2 - with: - version: ${{ inputs.version }} - version_bump_script: ./.github/scripts/bump-version.sh - dry_run: ${{ inputs.dry_run }} -``` - -### Post-publish - -To be run after separately publishing the [Python package](https://github.com/pypa/gh-action-pypi-publish#trusted-publishing). -Handles follow-up tasks related to publishing Python packages, including -signing `dist` files and uploading report assets to S3. -It will also push the following (dev) version to the source branch. -It will create a draft GitHub release and attach the signature files. -If `dry_run` is set, nothing will be pushed. - -The jobs should look something like: - -```yaml -publish: - name: Upload release to PyPI - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: all-dist-${{ github.run_id }} - path: dist/ - - name: Publish package distributions to PyPI - if: inputs.dry_run == 'false' - uses: pypa/gh-action-pypi-publish@release/v1 - -post-publish: - needs: [publish] - name: Handle post-publish actions - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - contents: write - attestations: write - security-events: write - steps: - - name: Setup - uses: mongodb-labs/drivers-github-tools/setup@v3 - with: - ... - - - uses: mongodb-labs/drivers-github-tools/python/post-publish@v3 - with: - version: ${{ inputs.version }} - following_version: ${{ inputs.following_version }} - version_bump_script: ./.github/scripts/bump-version.sh - product_name: winkerberos - token: ${{ github.token }} - dry_run: ${{ inputs.dry_run }} -``` - -### uv Lock Update - -This action runs `uv lock --upgrade` and opens a pull request with the resulting -lock file changes. It maintains a single open pull request: a subsequent run -updates the existing one rather than opening a second. - -The caller checks out the repository and puts `uv` on `PATH`. The cooldown on new -releases comes from `exclude-newer` in the consuming repo's `pyproject.toml`, not -from this action. - -```yaml -name: Update uv.lock - -on: - schedule: - - cron: "0 7 * * 1" - workflow_dispatch: - -# Runs must serialize: two at once would force push the same branch and race on -# the pull request. Keep the group static rather than keying it on the ref. -concurrency: - group: uv-lock-update - cancel-in-progress: false - -jobs: - update-lock: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - uses: astral-sh/setup-uv@v8 - - uses: mongodb-labs/drivers-github-tools/python/uv-lock-update@v3 - with: - app_id: ${{ vars.APP_ID }} - private_key: ${{ secrets.APP_PRIVATE_KEY }} -``` - -`app_id` and `private_key` are required unless `dry_run` is true. - -`base` defaults to the ref the workflow ran on, which is what a checkout with no -`ref` takes. If you check out a different ref, set `base` to match it, or the -pull request will contain every unrelated commit between the two branches. - -Every label named in `labels` must already exist in the repository, because -GitHub rejects a pull request that asks for an unknown one. - -Set `dry_run: true` to log the branch and pull request the action would have -created, without pushing or opening anything. - -## Python Labs Helper Scripts - -These scripts are opinionated helper scripts for Python releases in MongoDB Labs. -In contrast to the regulare Python scripts, it does not generate the -SSDLC compliance assets or upload anything to S3. - -### Pre-Publish - -Create a new tag. Verify the tag. -Push the commit and tag to the source branch unless `dry_run` is set. - -```yaml -- name: Setup - uses: mongodb-labs/drivers-github-tools/setup@v3 - with: - ... - -- uses: mongodb-labs/drivers-github-tools/python-labs/pre-publishv2 - with: - version_bump_script: ./.github/scripts/bump-version.sh - dry_run: ${{ inputs.dry_run }} -``` - -### Post-publish - -To be run after separately publishing the [Python package](https://github.com/pypa/gh-action-pypi-publish#trusted-publishing). -Handles follow-up tasks related to publishing Python packages. -It will push the following (dev) version to the source branch. -It will create a draft GitHub release with generated release notes. -If `dry_run` is set, nothing will be pushed. - -The jobs should look something like: - -```yaml -publish: - name: Upload release to PyPI - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: all-dist-${{ github.run_id }} - path: dist/ - - name: Publish package distributions to PyPI - if: inputs.dry_run == 'false' - uses: pypa/gh-action-pypi-publish@release/v1 - -post-publish: - needs: [publish] - name: Handle post-publish actions - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - contents: write - attestations: write - security-events: write - steps: - - name: Setup - uses: mongodb-labs/drivers-github-tools/setup@v3 - with: - ... - - - uses: mongodb-labs/drivers-github-tools/python-labs/post-publish@v3 - with: - following_version: ${{ inputs.following_version }} - version_bump_script: ./.github/scripts/bump-version.sh - product_name: python-bsonjs - token: ${{ github.token }} - dry_run: ${{ inputs.dry_run }} -``` \ No newline at end of file +- [`python/`](python/README.md) covers setup, pre-publish, post-publish, and uv + lock updates for the Python drivers. +- [`python-labs/`](python-labs/README.md) covers the same ground for MongoDB Labs + projects, without the SSDLC assets and S3 upload. diff --git a/python-labs/README.md b/python-labs/README.md new file mode 100644 index 0000000..299e079 --- /dev/null +++ b/python-labs/README.md @@ -0,0 +1,74 @@ +# Python Labs Actions + +Opinionated helper actions for Python releases in MongoDB Labs. Unlike the +[regular Python actions](../python/README.md), these do not generate the SSDLC +compliance assets or upload anything to S3. + +## Pre-Publish + +Create a new tag. Verify the tag. +Push the commit and tag to the source branch unless `dry_run` is set. + +```yaml +- name: Setup + uses: mongodb-labs/drivers-github-tools/setup@v3 + with: + ... + +- uses: mongodb-labs/drivers-github-tools/python-labs/pre-publishv2 + with: + version_bump_script: ./.github/scripts/bump-version.sh + dry_run: ${{ inputs.dry_run }} +``` + +## Post-publish + +To be run after separately publishing the [Python package](https://github.com/pypa/gh-action-pypi-publish#trusted-publishing). +Handles follow-up tasks related to publishing Python packages. +It will push the following (dev) version to the source branch. +It will create a draft GitHub release with generated release notes. +If `dry_run` is set, nothing will be pushed. + +The jobs should look something like: + +```yaml +publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: all-dist-${{ github.run_id }} + path: dist/ + - name: Publish package distributions to PyPI + if: inputs.dry_run == 'false' + uses: pypa/gh-action-pypi-publish@release/v1 + +post-publish: + needs: [publish] + name: Handle post-publish actions + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + contents: write + attestations: write + security-events: write + steps: + - name: Setup + uses: mongodb-labs/drivers-github-tools/setup@v3 + with: + ... + + - uses: mongodb-labs/drivers-github-tools/python-labs/post-publish@v3 + with: + following_version: ${{ inputs.following_version }} + version_bump_script: ./.github/scripts/bump-version.sh + product_name: python-bsonjs + token: ${{ github.token }} + dry_run: ${{ inputs.dry_run }} +``` diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..6173ada --- /dev/null +++ b/python/README.md @@ -0,0 +1,169 @@ +# Python Actions + +Opinionated helper actions for Python CI and releases. See the +[top-level README](../README.md) for actions shared across all drivers. + +## Setup + +This action installs Python, uv, and just, and runs the project's `just install` +recipe. It replaces the block of setup steps that Python driver repos otherwise +repeat in every CI job. + +```yaml +- uses: mongodb-labs/drivers-github-tools/python/setup@v3 + with: + python-version: "3.10" +``` + +Python comes from `actions/setup-python`, because the runner images already ship +several versions and installing from them is faster than having uv download a +managed interpreter. uv is then pointed at that exact interpreter through +`UV_PYTHON`, so it does not pick a different one based on the project's +`pyproject.toml` or `.python-version`. + +Set `run-install: "false"` for a job that does not need project dependencies: + +```yaml +- uses: mongodb-labs/drivers-github-tools/python/setup@v3 + with: + python-version: ${{ matrix.python-version }} + run-install: "false" +``` + +Leaving `run-install` at its default is also safe for a project with no justfile, +or one whose justfile has no `install` recipe: the step reports the skip and +succeeds. A recipe that exists and fails does fail the job. + +The action sets no resolution policy of its own. A repo that wants to hold back +newly published packages configures `exclude-newer` in its own `pyproject.toml` or +`uv.toml`, or commits a `uv.lock`. + +`enable-cache` defaults to `true` and is passed through to `astral-sh/setup-uv`. + +`allow-prereleases` defaults to `true`, so asking for a Python version with no +stable release yet gets the prerelease instead of failing. uv resolves such a +version on its own, so leaving this on keeps that behaviour for a repo moving to +this action. Set it to `"false"` for a job that should fail rather than quietly +test against a beta. + +## Pre-Publish + +Bump the version and create a new tag. Verify the tag. +Push the commit and tag to the source branch unless `dry_run` is set. + +```yaml +- name: Setup + uses: mongodb-labs/drivers-github-tools/setup@v3 + with: + ... + +- uses: mongodb-labs/drivers-github-tools/python/pre-publishv2 + with: + version: ${{ inputs.version }} + version_bump_script: ./.github/scripts/bump-version.sh + dry_run: ${{ inputs.dry_run }} +``` + +## Post-publish + +To be run after separately publishing the [Python package](https://github.com/pypa/gh-action-pypi-publish#trusted-publishing). +Handles follow-up tasks related to publishing Python packages, including +signing `dist` files and uploading report assets to S3. +It will also push the following (dev) version to the source branch. +It will create a draft GitHub release and attach the signature files. +If `dry_run` is set, nothing will be pushed. + +The jobs should look something like: + +```yaml +publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: all-dist-${{ github.run_id }} + path: dist/ + - name: Publish package distributions to PyPI + if: inputs.dry_run == 'false' + uses: pypa/gh-action-pypi-publish@release/v1 + +post-publish: + needs: [publish] + name: Handle post-publish actions + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + contents: write + attestations: write + security-events: write + steps: + - name: Setup + uses: mongodb-labs/drivers-github-tools/setup@v3 + with: + ... + + - uses: mongodb-labs/drivers-github-tools/python/post-publish@v3 + with: + version: ${{ inputs.version }} + following_version: ${{ inputs.following_version }} + version_bump_script: ./.github/scripts/bump-version.sh + product_name: winkerberos + token: ${{ github.token }} + dry_run: ${{ inputs.dry_run }} +``` + +## uv Lock Update + +This action runs `uv lock --upgrade` and opens a pull request with the resulting +lock file changes. It maintains a single open pull request: a subsequent run +updates the existing one rather than opening a second. + +The caller checks out the repository and puts `uv` on `PATH`. The cooldown on new +releases comes from `exclude-newer` in the consuming repo's `pyproject.toml`, not +from this action. + +```yaml +name: Update uv.lock + +on: + schedule: + - cron: "0 7 * * 1" + workflow_dispatch: + +# Runs must serialize: two at once would force push the same branch and race on +# the pull request. Keep the group static rather than keying it on the ref. +concurrency: + group: uv-lock-update + cancel-in-progress: false + +jobs: + update-lock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v8 + - uses: mongodb-labs/drivers-github-tools/python/uv-lock-update@v3 + with: + app_id: ${{ vars.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +`app_id` and `private_key` are required unless `dry_run` is true. + +`base` defaults to the ref the workflow ran on, which is what a checkout with no +`ref` takes. If you check out a different ref, set `base` to match it, or the +pull request will contain every unrelated commit between the two branches. + +Every label named in `labels` must already exist in the repository, because +GitHub rejects a pull request that asks for an unknown one. + +Set `dry_run: true` to log the branch and pull request the action would have +created, without pushing or opening anything. diff --git a/python/setup/action.yml b/python/setup/action.yml index 5b7fd6f..83fdc7a 100644 --- a/python/setup/action.yml +++ b/python/setup/action.yml @@ -14,13 +14,6 @@ inputs: enable-cache: description: Cache the uv download cache between runs default: "true" - exclude-newer: - description: >- - UV_EXCLUDE_NEWER value, which holds back packages published after it: a - date (2026-01-01), an RFC 3339 timestamp, or a duration ('7 days', 'P7D'). - Empty by default, which leaves resolution to the consuming repo's own - configuration — a committed uv.lock, or exclude-newer in its pyproject.toml. - default: "" run-install: description: >- Run the project's `just install` recipe. Set to 'false' for jobs that do @@ -41,26 +34,21 @@ runs: python-version: ${{ inputs.python-version }} allow-prereleases: ${{ inputs.allow-prereleases }} - - name: Configure uv + - name: Point uv at that interpreter shell: bash env: - # Inputs go through env rather than into the script body, so a value - # containing shell metacharacters cannot alter the commands below. + # The path goes through env rather than into the script body, so a value + # containing shell metacharacters cannot alter the command below. PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} - EXCLUDE_NEWER: ${{ inputs.exclude-newer }} # Exporting to the caller's later steps is the point of this step, and # GITHUB_ENV is the only way a composite action can do it, so the - # github-env audit has nothing actionable to say here. The values come - # from setup-python and this action's own inputs, not from a caller's - # untrusted content. + # github-env audit has nothing actionable to say here. The value comes from + # setup-python, not from a caller's untrusted content. run: | # zizmor: ignore[github-env] # An absolute interpreter path makes the choice exact: uv uses this # interpreter and never falls back to downloading one, whatever the # consuming project's pyproject.toml or .python-version asks for. echo "UV_PYTHON=$PYTHON_PATH" >> "$GITHUB_ENV" - if [ -n "$EXCLUDE_NEWER" ]; then - echo "UV_EXCLUDE_NEWER=$EXCLUDE_NEWER" >> "$GITHUB_ENV" - fi - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 From da9b1e22992959ae8cc60d34d6a0eda3fdd31911 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 14 Aug 2026 17:08:04 -0500 Subject: [PATCH 7/7] Make run-install opt-in The install ran by default, which meant the action needed to detect a missing justfile or install recipe and skip gracefully. That detection was the only reason run_install.sh and its tests existed: defensive work for consumers that never wanted an install. Opting in removes the need for it. A caller asking for `run-install: true` has an install recipe, so a missing one should fail rather than be skipped silently, and the step is a plain `just install` again. Of mongo-python-driver's eight converted jobs, four install and four do not, so neither default saves the caller any lines. Putting tools on PATH is what this action promises; installing dependencies is a side effect a job should ask for. --- .github/workflows/ci.yml | 8 +-- python/README.md | 17 +++-- python/setup/action.yml | 11 ++-- python/setup/run_install.sh | 24 ------- python/setup/test_run_install.sh | 103 ------------------------------- 5 files changed, 16 insertions(+), 147 deletions(-) delete mode 100755 python/setup/run_install.sh delete mode 100755 python/setup/test_run_install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83b3d56..8db61f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,15 +69,11 @@ jobs: with: persist-credentials: false - # The tests below need uv and just, and the python/setup action installs - # both. Using it here also exercises it end to end on every run, including - # its skip path: this repo has no justfile, so `just install` is skipped. + # The tests below need uv, which the python/setup action installs. Using it + # here also exercises the action itself on every run. - name: Install Python tooling uses: $/python/setup - - name: Run setup tests - run: bash python/setup/test_run_install.sh - - name: Run uv lock update tests run: | bash python/uv-lock-update/test_diff_lock.sh diff --git a/python/README.md b/python/README.md index abf143b..043d6ae 100644 --- a/python/README.md +++ b/python/README.md @@ -5,9 +5,8 @@ Opinionated helper actions for Python CI and releases. See the ## Setup -This action installs Python, uv, and just, and runs the project's `just install` -recipe. It replaces the block of setup steps that Python driver repos otherwise -repeat in every CI job. +This action puts Python, uv, and just on `PATH`. It replaces the block of setup +steps that Python driver repos otherwise repeat in every CI job. ```yaml - uses: mongodb-labs/drivers-github-tools/python/setup@v3 @@ -21,18 +20,18 @@ managed interpreter. uv is then pointed at that exact interpreter through `UV_PYTHON`, so it does not pick a different one based on the project's `pyproject.toml` or `.python-version`. -Set `run-install: "false"` for a job that does not need project dependencies: +Add `run-install: "true"` for a job that also needs the project's dependencies: ```yaml - uses: mongodb-labs/drivers-github-tools/python/setup@v3 with: - python-version: ${{ matrix.python-version }} - run-install: "false" + python-version: "3.10" + run-install: "true" ``` -Leaving `run-install` at its default is also safe for a project with no justfile, -or one whose justfile has no `install` recipe: the step reports the skip and -succeeds. A recipe that exists and fails does fail the job. +That runs the project's `just install` recipe, and requires one to exist. It is +off by default because installing dependencies changes the job's environment, and +plenty of jobs only need the tools. The action sets no resolution policy of its own. A repo that wants to hold back newly published packages configures `exclude-newer` in its own `pyproject.toml` or diff --git a/python/setup/action.yml b/python/setup/action.yml index 83fdc7a..a7b724b 100644 --- a/python/setup/action.yml +++ b/python/setup/action.yml @@ -16,10 +16,11 @@ inputs: default: "true" run-install: description: >- - Run the project's `just install` recipe. Set to 'false' for jobs that do - not need project dependencies. When 'true' but the project has no justfile - or no `install` recipe, the step is skipped rather than failing. - default: "true" + Run the project's `just install` recipe. Off by default, because putting + the tools on PATH is what this action is for and installing dependencies + changes the job's environment. A job that needs them asks for them. The + project must define an `install` recipe, or the step fails. + default: "false" runs: using: composite @@ -62,4 +63,4 @@ runs: - name: Run just install if: inputs.run-install == 'true' shell: bash - run: ${{ github.action_path }}/run_install.sh + run: just install diff --git a/python/setup/run_install.sh b/python/setup/run_install.sh deleted file mode 100755 index adc740a..0000000 --- a/python/setup/run_install.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Run `just install` when the project defines that recipe, and skip cleanly when -# it does not. Consumers that only want uv and just on PATH — or that have no -# justfile at all — get a no-op rather than a failure, so the same action serves -# both without the caller having to know which case it is in. -set -euo pipefail - -# `just --summary` prints the recipe names on one space-separated line and exits -# non-zero only when there is no justfile to read. A justfile with no recipes -# exits 0 with empty output, so it falls through to the recipe check below. -if ! SUMMARY=$(just --summary 2>/dev/null); then - echo "Skipping 'just install': no justfile found." - exit 0 -fi - -# Split to one name per line and match whole lines. Substring matching would fire -# on `install-deps`, `preinstall`, and `uninstall` — and `grep -w` is no help, -# because it counts `-` as a word boundary and so still matches `install-deps`. -if ! echo "$SUMMARY" | tr ' ' '\n' | grep -qx 'install'; then - echo "Skipping 'just install': no 'install' recipe in the justfile." - exit 0 -fi - -just install diff --git a/python/setup/test_run_install.sh b/python/setup/test_run_install.sh deleted file mode 100755 index 93099e0..0000000 --- a/python/setup/test_run_install.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT="$(cd "$(dirname "$0")" && pwd)/run_install.sh" -FAIL=0 -TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT - -check() { - local desc="$1" - local expected="$2" - local actual="$3" - if [ "$actual" = "$expected" ]; then - echo "OK: $desc" - else - echo "FAIL: $desc" - echo " expected: $expected" - echo " actual: $actual" - FAIL=1 - fi -} - -check_contains() { - local desc="$1" - local needle="$2" - local haystack="$3" - if echo "$haystack" | grep -qF -- "$needle"; then - echo "OK: $desc" - else - echo "FAIL: $desc" - echo " expected to find: $needle" - echo " in: $haystack" - FAIL=1 - fi -} - -# Each case runs in its own directory. `just` walks up to find a justfile, so the -# "no justfile" case needs a tree with none above it: TMPDIR is under the system -# temp dir, not the repo. -run_script() { - local case_name="$1" - local justfile_body="${2-}" - WORKDIR="$TMPDIR/$case_name" - mkdir -p "$WORKDIR" - if [ -n "$justfile_body" ]; then - printf '%s' "$justfile_body" > "$WORKDIR/justfile" - fi - STATUS=0 - OUTPUT=$(cd "$WORKDIR" && bash "$SCRIPT" 2>&1) || STATUS=$? -} - -ran_marker() { [ -f "$WORKDIR/ran" ] && echo "ran" || echo "did not run"; } - -# The whole point of the action's optional install step: a project that defines -# `install` gets it run. -run_script "has-install" 'install: - @touch ran -' -check "install recipe: exit status is 0" "0" "$STATUS" -check "install recipe: it is run" "ran" "$(ran_marker)" - -# A recipe named `install-deps` contains "install" as a prefix. `grep -w` treats -# `-` as a word boundary and would match it, running the wrong recipe name (or -# failing outright). Matching must be on the whole recipe name. -run_script "install-prefix" 'install-deps: - @touch ran -' -check "install-deps only: exit status is 0" "0" "$STATUS" -check "install-deps only: nothing is run" "did not run" "$(ran_marker)" -check_contains "install-deps only: the skip is reported" "no 'install' recipe" "$OUTPUT" - -# The same substring trap from the other side: `uninstall` and `preinstall` both -# contain "install" and must not be mistaken for it. -run_script "install-substrings" 'uninstall: - @touch ran - -preinstall: - @touch ran -' -check "uninstall/preinstall only: exit status is 0" "0" "$STATUS" -check "uninstall/preinstall only: nothing is run" "did not run" "$(ran_marker)" - -# A repo with no justfile at all must be a clean skip, not a failure: this is the -# case for consumers that use the action purely to get uv and just on PATH. -run_script "no-justfile" -check "no justfile: exit status is 0" "0" "$STATUS" -check_contains "no justfile: the skip is reported" "no justfile" "$OUTPUT" - -# A justfile with recipes but no `install` is a skip, and one with no recipes at -# all must not trip the "no justfile" path either. -run_script "no-recipes" '# a justfile with only a comment -' -check "justfile with no recipes: exit status is 0" "0" "$STATUS" -check_contains "justfile with no recipes: the skip is reported" "no 'install' recipe" "$OUTPUT" - -# A failing install must fail the job. Swallowing it would let CI proceed with -# missing dependencies and report a confusing downstream error instead. -run_script "failing-install" 'install: - @exit 3 -' -check "failing install recipe: the failure propagates" "3" "$STATUS" - -exit $FAIL