diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43ce8c3..b0bc9a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 32a5a79..bd80688 100644 --- a/README.md +++ b/README.md @@ -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 }} +``` + +`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. diff --git a/python/uv-lock-update/action.yml b/python/uv-lock-update/action.yml new file mode 100644 index 0000000..2095ad3 --- /dev/null +++ b/python/uv-lock-update/action.yml @@ -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 diff --git a/python/uv-lock-update/decide_pr_action.sh b/python/uv-lock-update/decide_pr_action.sh new file mode 100755 index 0000000..958c072 --- /dev/null +++ b/python/uv-lock-update/decide_pr_action.sh @@ -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 diff --git a/python/uv-lock-update/diff_lock.py b/python/uv-lock-update/diff_lock.py new file mode 100644 index 0000000..a15b4e9 --- /dev/null +++ b/python/uv-lock-update/diff_lock.py @@ -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 ", 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() diff --git a/python/uv-lock-update/test_decide_pr_action.sh b/python/uv-lock-update/test_decide_pr_action.sh new file mode 100755 index 0000000..3d713ca --- /dev/null +++ b/python/uv-lock-update/test_decide_pr_action.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/decide_pr_action.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 +} + +FAKE_GH="$TMPDIR/gh" +cat > "$FAKE_GH" <> "$TMPDIR/gh_calls.log" +if [ "\$1" = "pr" ] && [ "\$2" = "list" ]; then + # Apply the caller's own --jq against the canned response, so the real filter + # is exercised rather than a copy of it that could drift. + JQ_EXPR='.[0].number // empty' + while [ \$# -gt 0 ]; do + if [ "\$1" = "--jq" ]; then JQ_EXPR="\$2"; fi + shift + done + jq -r "\$JQ_EXPR" "$TMPDIR/pr_list_response.json" +fi +FAKE_GH_EOF +chmod +x "$FAKE_GH" +export PATH="$TMPDIR:$PATH" + +# A non-default base throughout, so a hardcoded "main" cannot pass. +run_script() { + local pr_list_json="$1" + local dry_run="$2" + echo "$pr_list_json" > "$TMPDIR/pr_list_response.json" + : > "$TMPDIR/gh_calls.log" + BRANCH="uv-lock-update" \ + BASE="v4.16" \ + TITLE="Automation: Update uv.lock" \ + BODY="## Updated packages" \ + LABELS="dependencies" \ + DRY_RUN="$dry_run" \ + bash "$SCRIPT" > "$TMPDIR/output.log" 2>&1 || true +} + +gh_call() { grep "^pr $1" "$TMPDIR/gh_calls.log" || true; } +mutating_calls() { grep -E '^pr (create|edit)' "$TMPDIR/gh_calls.log" || true; } + +# No open PR: create one targeting BASE. The lookup must not filter on base, or +# a PR a reviewer retargeted is missed and a second PR opens on the branch. +run_script '[]' "false" +check "no open PR: list finds the branch by head and state alone" \ + "pr list --head uv-lock-update --state open --json number,isCrossRepository --jq map(select(.isCrossRepository == false)) | .[0].number // empty" \ + "$(gh_call list)" +check "no open PR: a PR is created against the configured base" \ + "pr create --title Automation: Update uv.lock --body ## Updated packages --base v4.16 --label dependencies --head uv-lock-update" \ + "$(gh_call create)" +check "no open PR: nothing is edited" "" "$(gh_call edit)" + +# A fork can open a pull request whose head branch has the same name, and gh +# cannot filter that out for us. Editing it would rewrite a stranger's pull +# request, so it must be ignored and a fresh one created instead. +run_script '[{"number": 99, "isCrossRepository": true}]' "false" +check "fork PR on the same branch name is ignored" "" "$(gh_call edit)" +check "fork PR on the same branch name: ours is created instead" \ + "pr create --title Automation: Update uv.lock --body ## Updated packages --base v4.16 --label dependencies --head uv-lock-update" \ + "$(gh_call create)" + +# Open PR found by head branch even though its base differs from BASE: it is +# refreshed in place rather than duplicated. +run_script '[{"number": 42, "isCrossRepository": false}]' "false" +check "open PR: it is edited in place with the new body and label" \ + "pr edit 42 --body ## Updated packages --add-label dependencies" \ + "$(gh_call edit)" +check "open PR: no second PR is created" "" "$(gh_call create)" + +# `gh pr create --dry-run` documents that it "may still push git changes", so a +# dry run must report the decision without reaching any mutating gh command. +run_script '[{"number": 42, "isCrossRepository": false}]' "true" +check "open PR + dry run: no mutating gh call" "" "$(mutating_calls)" +check_contains "open PR + dry run: output names the PR it would update" \ + "Would update PR #42" "$(cat "$TMPDIR/output.log")" +# The body appears nowhere else in the dry-run output, so finding it proves the +# generated summary was logged and not just the create-or-update decision. +check_contains "open PR + dry run: the body is logged" \ + "## Updated packages" "$(cat "$TMPDIR/output.log")" + +run_script '[]' "true" +check "no open PR + dry run: no mutating gh call" "" "$(mutating_calls)" +check_contains "no open PR + dry run: output names the branch and base" \ + "from uv-lock-update into v4.16" "$(cat "$TMPDIR/output.log")" +check_contains "no open PR + dry run: the body is logged" \ + "## Updated packages" "$(cat "$TMPDIR/output.log")" + +exit $FAIL diff --git a/python/uv-lock-update/test_diff_lock.sh b/python/uv-lock-update/test_diff_lock.sh new file mode 100755 index 0000000..07503d7 --- /dev/null +++ b/python/uv-lock-update/test_diff_lock.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/diff_lock.py" +FAIL=0 +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Invoke diff_lock.py exactly as update_lock.sh does, so the interpreter under +# test is the one production uses rather than whatever python3 is on PATH. +run_diff() { + uv run --no-project --python '>=3.11' python "$SCRIPT" "$@" +} + +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 +} + +# One realistic uv.lock pair covering every case at once. Real locks open with a +# version preamble and an [options] table, and uv writes one [[package]] entry +# per resolution fork, so a package can appear more than once. +# pymongo root project entry uv writes with no version key +# black single locked version changed +# cffi gains a second forked version +# click added +# urllib3 removed +# flake8 unchanged +# sphinx one of two forked versions changed +# anyio two forked versions unchanged, written in a different entry order +cat > "$TMPDIR/old.lock" <<'EOF' +version = 1 +revision = 3 +requires-python = ">=3.10" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" +[[package]] +name = "pymongo" +source = { editable = "." } +[[package]] +name = "black" +version = "23.1.0" +[[package]] +name = "cffi" +version = "1.17.1" +[[package]] +name = "urllib3" +version = "2.0.0" +[[package]] +name = "flake8" +version = "6.0.0" +[[package]] +name = "sphinx" +version = "7.4.7" +[[package]] +name = "sphinx" +version = "8.1.3" +[[package]] +name = "anyio" +version = "4.11.0" +[[package]] +name = "anyio" +version = "4.5.2" +EOF + +cat > "$TMPDIR/new.lock" <<'EOF' +version = 1 +revision = 3 +requires-python = ">=3.10" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" +[[package]] +name = "pymongo" +source = { editable = "." } +[[package]] +name = "black" +version = "23.3.0" +[[package]] +name = "cffi" +version = "1.17.1" +[[package]] +name = "cffi" +version = "2.0.0" +[[package]] +name = "click" +version = "8.1.0" +[[package]] +name = "flake8" +version = "6.0.0" +[[package]] +name = "sphinx" +version = "7.4.7" +[[package]] +name = "sphinx" +version = "8.2.0" +[[package]] +name = "anyio" +version = "4.5.2" +[[package]] +name = "anyio" +version = "4.11.0" +EOF + +# Capture stdout only. uv writes progress to stderr on a cold cache, which would +# otherwise contaminate the assertions below. $STATUS still catches a crash. +set +e +ACTUAL=$(run_diff "$TMPDIR/old.lock" "$TMPDIR/new.lock") +STATUS=$? +set -e + +check "version-less root entry does not crash the diff" "0" "$STATUS" + +check "changed version" \ + "- black: \`23.1.0\` → \`23.3.0\`" \ + "$(echo "$ACTUAL" | grep '^- black:' || true)" + +check "package gains a forked version" \ + "- cffi: \`1.17.1\` → \`1.17.1\`, \`2.0.0\`" \ + "$(echo "$ACTUAL" | grep '^- cffi:' || true)" + +check "added package" \ + "- click: added \`8.1.0\`" \ + "$(echo "$ACTUAL" | grep '^- click:' || true)" + +check "removed package" \ + "- urllib3: removed \`2.0.0\`" \ + "$(echo "$ACTUAL" | grep '^- urllib3:' || true)" + +check "one of several forked versions changed" \ + "- sphinx: \`7.4.7\`, \`8.1.3\` → \`7.4.7\`, \`8.2.0\`" \ + "$(echo "$ACTUAL" | grep '^- sphinx:' || true)" + +check "unchanged package omitted" \ + "" \ + "$(echo "$ACTUAL" | grep '^- flake8:' || true)" + +check "unchanged forked versions omitted whatever the entry order" \ + "" \ + "$(echo "$ACTUAL" | grep '^- anyio:' || true)" + +check "version-less root project is not reported" \ + "" \ + "$(echo "$ACTUAL" | grep 'pymongo' || true)" + +check "only the changed packages are reported" \ + "5" \ + "$(echo "$ACTUAL" | grep -c '^-' || true)" + +REVERSED=$(run_diff "$TMPDIR/new.lock" "$TMPDIR/old.lock") +check "package loses a forked version" \ + "- cffi: \`1.17.1\`, \`2.0.0\` → \`1.17.1\`" \ + "$(echo "$REVERSED" | grep '^- cffi:' || true)" + +check "identical inputs produce empty output" \ + "" \ + "$(run_diff "$TMPDIR/new.lock" "$TMPDIR/new.lock")" + +# Forked versions are listed in version order, not string order. Sorting these +# as strings puts 10.0.0 before 9.0.0. +cat > "$TMPDIR/sort_old.lock" <<'EOF' +[[package]] +name = "widget" +version = "9.0.0" +EOF + +cat > "$TMPDIR/sort_new.lock" <<'EOF' +[[package]] +name = "widget" +version = "9.0.0" + +[[package]] +name = "widget" +version = "10.0.0" + +[[package]] +name = "widget" +version = "9.10.0" +EOF + +check "forked versions sort numerically, not lexically" \ + "- widget: \`9.0.0\` → \`9.0.0\`, \`9.10.0\`, \`10.0.0\`" \ + "$(run_diff "$TMPDIR/sort_old.lock" "$TMPDIR/sort_new.lock")" + +exit $FAIL diff --git a/python/uv-lock-update/test_update_lock.sh b/python/uv-lock-update/test_update_lock.sh new file mode 100755 index 0000000..b2a83a0 --- /dev/null +++ b/python/uv-lock-update/test_update_lock.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/update_lock.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 +} + +# update_lock.sh reaches diff_lock.py and decide_pr_action.sh through +# $ACTION_PATH, so pointing that at stubs isolates its own orchestration from +# the real diff and from any gh call. +STUB="$TMPDIR/action" +mkdir -p "$STUB" +printf 'print("- demo: `1.0` -> `2.0`")\n' > "$STUB/diff_lock.py" +cat > "$STUB/decide_pr_action.sh" < "$TMPDIR/handoff.log" +STUB_EOF +chmod +x "$STUB/decide_pr_action.sh" + +# A repo whose uv.lock differs from HEAD, so the "no changes" early exit is not +# taken and the script runs its full body. +REPO="$TMPDIR/repo" +mkdir -p "$REPO" +git -C "$REPO" init -q +git -C "$REPO" config user.email test@example.com +git -C "$REPO" config user.name test +printf 'old\n' > "$REPO/uv.lock" +git -C "$REPO" add uv.lock +git -C "$REPO" commit -qm init +printf 'new\n' > "$REPO/uv.lock" +printf 'old\n' > "$TMPDIR/uv.lock.before" + +# Every variable update_lock.sh documents as required on the dry-run path. +# GH_TOKEN and GITHUB_REPOSITORY are deliberately absent: they are referenced +# only by the push, which a dry run skips. +run_update() { + local skip="${1:-}" + : > "$TMPDIR/handoff.log" + ( + cd "$REPO" || exit 1 + export BRANCH=uv-lock-update + export BASE=main + export LABELS=dependencies + export DRY_RUN=true + export OLD_LOCK="$TMPDIR/uv.lock.before" + export ACTION_PATH="$STUB" + # Unset after exporting, so the variable is genuinely absent rather than + # being reassigned by a later argument on the same command. + if [ -n "$skip" ]; then + unset "$skip" + fi + bash "$SCRIPT" + ) > "$TMPDIR/out.log" 2>&1 +} + +# A dry run hands off the values the action promises, and leaves the repo alone. +set +e +run_update +STATUS=$? +set -e + +check "dry run succeeds" "0" "$STATUS" +check "branch is handed off" "BRANCH=uv-lock-update" "$(grep '^BRANCH=' "$TMPDIR/handoff.log" || true)" +check "base is handed off" "BASE=main" "$(grep '^BASE=' "$TMPDIR/handoff.log" || true)" +check "labels are handed off" "LABELS=dependencies" "$(grep '^LABELS=' "$TMPDIR/handoff.log" || true)" +check "dry run flag is handed off" "DRY_RUN=true" "$(grep '^DRY_RUN=' "$TMPDIR/handoff.log" || true)" +check "title is supplied by update_lock.sh" \ + "TITLE=Automation: Update uv.lock" \ + "$(grep '^TITLE=' "$TMPDIR/handoff.log" || true)" +check "body starts with the summary heading" \ + "BODY_FIRST_LINE=## Updated packages" \ + "$(grep '^BODY_FIRST_LINE=' "$TMPDIR/handoff.log" || true)" +check "body carries the diff output" \ + 'BODY_LAST_LINE=- demo: `1.0` -> `2.0`' \ + "$(grep '^BODY_LAST_LINE=' "$TMPDIR/handoff.log" || true)" + +check "dry run creates no commit" "1" "$(git -C "$REPO" rev-list --count HEAD)" +check "dry run leaves the branch alone" \ + "" \ + "$(git -C "$REPO" branch --list uv-lock-update)" + +# Each documented variable is genuinely required: unset it and the script fails +# rather than proceeding with an empty value. +# Change detection compares against the pre-upgrade copy, not against HEAD. A +# workspace that was already dirty must still count as "no change" when the +# upgrade produced nothing, or the pull request body would report no version +# changes while claiming there were some. +printf 'new\n' > "$TMPDIR/uv.lock.unchanged" +set +e +( + cd "$REPO" || exit 1 + export BRANCH=uv-lock-update BASE=main LABELS=dependencies DRY_RUN=true + export OLD_LOCK="$TMPDIR/uv.lock.unchanged" ACTION_PATH="$STUB" + bash "$SCRIPT" +) > "$TMPDIR/nochange.log" 2>&1 +STATUS=$? +set -e + +check "dirty workspace with an unchanged lock exits cleanly" "0" "$STATUS" +check "dirty workspace with an unchanged lock reports no changes" \ + "No changes detected, skipping PR creation" \ + "$(cat "$TMPDIR/nochange.log")" + +for VAR in BRANCH BASE LABELS DRY_RUN OLD_LOCK ACTION_PATH; do + set +e + run_update "$VAR" + STATUS=$? + set -e + check "missing $VAR fails the run" "1" "$([ "$STATUS" -ne 0 ] && echo 1 || echo 0)" +done + +exit $FAIL diff --git a/python/uv-lock-update/update_lock.sh b/python/uv-lock-update/update_lock.sh new file mode 100755 index 0000000..59afc0d --- /dev/null +++ b/python/uv-lock-update/update_lock.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Summarize the upgraded lock file, commit it to the bot owned branch, push, and +# hand off to decide_pr_action.sh to open or refresh the pull request. +# +# Required environment: GH_TOKEN, BRANCH, BASE, LABELS, DRY_RUN, OLD_LOCK, +# ACTION_PATH, and GITHUB_REPOSITORY from the Actions runtime. +set -euo pipefail + +# Compare against the copy taken before the upgrade, which is the same baseline +# diff_lock.py summarizes from. `git diff` would compare against HEAD instead, so +# a workspace that was already dirty would disagree with the summary and could +# open a pull request whose body reports no version changes. +if cmp -s "$OLD_LOCK" uv.lock; then + echo "No changes detected, skipping PR creation" + exit 0 +fi + +# diff_lock.py needs tomllib, so Python 3.11 or newer. uv is already a +# requirement of this action, so let it supply a suitable interpreter rather +# than depending on whatever the runner's python3 happens to be. +UPDATES=$(uv run --no-project --python '>=3.11' python "$ACTION_PATH/diff_lock.py" "$OLD_LOCK" uv.lock) + +if [ -n "$UPDATES" ]; then + BODY="## Updated packages"$'\n\n'"${UPDATES}" +else + BODY="No package version changes. The lock file metadata changed; see the file diff for details." +fi + +# Everything below mutates state, so a dry run skips all of it and leaves the +# workspace untouched. decide_pr_action.sh still runs and still reports the +# decision: it finds an existing pull request by querying the remote for the head +# branch, so it needs no local branch or commit. +if [ "$DRY_RUN" != "true" ]; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add uv.lock + git commit -m "Update uv.lock" + + # The branch is exclusively bot owned and rebuilt fresh from the checked-out + # ref every run, so overwriting whatever is currently on the remote (a + # still-open PR's branch, a stale closed-PR branch, or nothing) is always safe + # and always correct. + # Supply credentials through a helper that reads GH_TOKEN from the + # environment, so the token is never written to .git/config, never embedded in + # the remote URL where git error output could echo it, and never passed as a + # command argument. The empty first -c clears any inherited helper. + git -c credential.helper= \ + -c 'credential.helper=!f() { test "$1" = get && echo username=x-access-token && echo "password=$GH_TOKEN"; }; f' \ + push --force "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" +fi + +BRANCH="$BRANCH" BASE="$BASE" TITLE="Automation: Update uv.lock" \ +BODY="$BODY" LABELS="$LABELS" DRY_RUN="$DRY_RUN" \ + bash "$ACTION_PATH/decide_pr_action.sh"