Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,22 @@ jobs:
id: npm-ci-test
working-directory: ${{ matrix.working-directory }}
run: npm run ci-test

test-python:
name: Python Tests
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2

- name: Run uv lock update tests
run: |
bash python/uv-lock-update/test_diff_lock.sh
bash python/uv-lock-update/test_decide_pr_action.sh
bash python/uv-lock-update/test_update_lock.sh
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,56 @@ post-publish:
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 }}
```
Comment thread
aclark4life marked this conversation as resolved.

`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.
Expand Down
82 changes: 82 additions & 0 deletions python/uv-lock-update/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: uv Lock Update
description: Runs `uv lock --upgrade` and opens or refreshes a single pull request with the lock file changes
inputs:
app_id:
description: GitHub App ID for authenticated pushes. Required unless dry_run is true.
default: ""
private_key:
description: GitHub App private key for authenticated pushes. Required unless dry_run is true.
default: ""
branch:
description: Branch name for the update pull request
default: uv-lock-update
base:
description: >-
Base branch for the update pull request. Defaults to the ref the workflow
ran on, which is what actions/checkout checks out when given no ref.
default: ""
labels:
description: Labels to apply to the pull request
default: dependencies
dry_run:
description: If 'true', report intended actions without pushing or modifying pull requests
default: "false"

runs:
using: composite
steps:
# A dry run only reads, so it needs no app token and stays usable before an
# App is configured. Real runs push and open pull requests, so they require
# one and the next step fails fast when it is missing.
- name: Generate app token
id: app-token
if: inputs.dry_run != 'true' && inputs.app_id != '' && inputs.private_key != ''
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ inputs.app_id }}
private-key: ${{ inputs.private_key }}
permission-contents: write
permission-pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

- name: Require an app token outside dry runs
if: inputs.dry_run != 'true' && (inputs.app_id == '' || inputs.private_key == '')
shell: bash
run: |
echo "::error::app_id and private_key are both required unless dry_run is true. Falling back to github.token is not supported here: pull requests opened with it do not trigger workflow runs, so the pull request would arrive with no CI and look ready to merge."
exit 1

- name: Save current lock file
shell: bash
env:
OLD_LOCK: ${{ runner.temp }}/uv.lock.before
run: |
if [ ! -f uv.lock ]; then
echo "::error::No uv.lock found in ${PWD}. Run 'uv lock' and commit the result before using this action."
exit 1
fi
cp uv.lock "$OLD_LOCK"

- name: Upgrade the lock file
shell: bash
run: uv lock --upgrade

- name: Create or update the pull request
shell: bash
env:
# Only a dry run may use github.token. A live run gets the app token or
# nothing, so a future edit cannot silently reintroduce a fallback that
# opens pull requests CI will never run on.
GH_TOKEN: ${{ inputs.dry_run == 'true' && github.token || steps.app-token.outputs.token }}
# Target the repository explicitly rather than letting gh infer it from
# the git remote, so the pull request lookup cannot depend on how the
# caller configured its checkout.
GH_REPO: ${{ github.repository }}
BRANCH: ${{ inputs.branch }}
BASE: ${{ inputs.base || github.ref_name }}
LABELS: ${{ inputs.labels }}
DRY_RUN: ${{ inputs.dry_run }}
OLD_LOCK: ${{ runner.temp }}/uv.lock.before
ACTION_PATH: ${{ github.action_path }}
run: ${{ github.action_path }}/update_lock.sh
47 changes: 47 additions & 0 deletions python/uv-lock-update/decide_pr_action.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Decide whether to open a new lock-update PR or refresh the existing open one
# on the same branch. An open PR already on $BRANCH is updated in place. A
# merged or manually closed PR is not "open", so this falls through to creating
# a fresh one, with no extra state to track.
#
# Required environment: BRANCH, BASE, TITLE, BODY, LABELS, DRY_RUN, and
# GH_TOKEN plus GH_REPO for gh itself.
set -euo pipefail

# Deliberately no --base filter here: if a reviewer retargets the open PR to
# a different base, this query must still find it by head branch alone, or
# the next run falls through to `gh pr create` and GitHub allows a second
# open PR from the same force-pushed branch. New PRs still target $BASE below.
# --head matches on branch name only, and gh has no --owner filter, so a fork
# with a branch of the same name could otherwise match and we would edit someone
# else's pull request. isCrossRepository excludes anything not from this repo.
PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number,isCrossRepository --jq 'map(select(.isCrossRepository == false)) | .[0].number // empty')

if [ "$DRY_RUN" = "true" ]; then
# `gh pr create --dry-run` documents that it "may still push git changes",
# so a dry run reports the decision and never reaches a mutating gh command.
# The listing above is read only and safe.
if [ -n "$PR_NUMBER" ]; then
echo "Would update PR #$PR_NUMBER on $BRANCH"
else
echo "Would create PR \"$TITLE\" from $BRANCH into $BASE"
fi
# Log the body too, so a dry run verifies the generated summary and not just
# the create-or-update decision.
echo "::group::Pull request body"
echo "$BODY"
echo "::endgroup::"
exit 0
fi

if [ -n "$PR_NUMBER" ]; then
gh pr edit "$PR_NUMBER" --body "$BODY" --add-label "$LABELS"
echo "Updated PR #$PR_NUMBER"
else
gh pr create \
--title "$TITLE" \
--body "$BODY" \
--base "$BASE" \
--label "$LABELS" \
--head "$BRANCH"
fi
87 changes: 87 additions & 0 deletions python/uv-lock-update/diff_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Diff two uv.lock files and print a markdown list of package version changes.

Requires Python 3.11 or newer for `tomllib`. The action pins this with
`uv run --python '>=3.11'`.
"""

import re
import sys
import tomllib


def version_sort_key(version: str) -> tuple[tuple[int, int | str], ...]:
"""Sort key ordering numeric version segments numerically.

Plain string sorting puts ``10.0.0`` before ``9.0.0``. Segments that are all
digits compare as integers; anything else compares as a string, and numeric
segments sort before non-numeric ones at the same position so ``1.0``
precedes ``1.0post1``. Pre-release ordering is not modelled, since this only
determines the order versions are listed in a summary.
"""
return tuple(
(0, int(part)) if part.isdigit() else (1, part)
for part in re.split(r"[._-]", version)
)


def load_versions(path: str) -> dict[str, list[str]]:
"""Map each package name to its sorted list of locked versions.

uv writes one ``[[package]]`` entry per resolution fork, so a package
resolved differently across Python versions appears more than once. Keying
on name alone would keep only the last entry parsed.

Entries without a ``version`` key are skipped. uv writes such an entry for
the root project itself (``source = { editable = "." }``), which has no
locked version and does not belong in a version change summary.
"""
with open(path, "rb") as f:
data = tomllib.load(f)
versions: dict[str, set[str]] = {}
for pkg in data.get("package", []):
if "version" not in pkg:
continue
versions.setdefault(pkg["name"], set()).add(pkg["version"])
return {
name: sorted(found, key=version_sort_key)
for name, found in versions.items()
}


def format_versions(versions: list[str]) -> str:
return ", ".join(f"`{version}`" for version in versions)


def diff_versions(
old: dict[str, list[str]], new: dict[str, list[str]]
) -> list[str]:
lines = []
for name in sorted(set(old) | set(new)):
old_versions = old.get(name)
new_versions = new.get(name)
if old_versions == new_versions:
continue
if old_versions is None:
lines.append(f"- {name}: added {format_versions(new_versions)}")
elif new_versions is None:
lines.append(f"- {name}: removed {format_versions(old_versions)}")
else:
lines.append(
f"- {name}: {format_versions(old_versions)}"
f" → {format_versions(new_versions)}"
)
return lines


def main() -> None:
if len(sys.argv) != 3:
print("Usage: diff_lock.py <old.lock> <new.lock>", file=sys.stderr)
sys.exit(2)
old = load_versions(sys.argv[1])
new = load_versions(sys.argv[2])
for line in diff_versions(old, new):
print(line)


if __name__ == "__main__":
main()
Loading
Loading