From c4b6234b9365da3946dc0f163ac60fc55efe5777 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 3 Aug 2026 06:57:24 -0500 Subject: [PATCH 1/5] chore: ignore local .worktrees/ directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1e7d098..9bdc7e8 100644 --- a/.gitignore +++ b/.gitignore @@ -209,3 +209,6 @@ __marimo__/ # CLAUDE.md is an intentional, tracked project file — override the user's global ~/.gitignore exclusion !CLAUDE.md + +# Local git worktrees +.worktrees/ From 66724d82cdd05c57de771f3d1c9c40664e68a1e8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 3 Aug 2026 11:42:14 -0500 Subject: [PATCH 2/5] feat: add decide_pr_action.sh to update existing lock-update PRs --- .github/workflows/tests.yml | 2 + .../poetry-lock-update/decide_pr_action.sh | 35 ++++++++ .../test_decide_pr_action.sh | 87 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100755 actions/poetry-lock-update/decide_pr_action.sh create mode 100755 actions/poetry-lock-update/test_decide_pr_action.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2229b47..1b7c892 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -105,6 +105,8 @@ jobs: run: bash actions/pre-commit-autoupdate/test_parse_diff.sh - name: Test diff_lock.py run: bash actions/poetry-lock-update/test_diff_lock.sh + - name: Test decide_pr_action.sh + run: bash actions/poetry-lock-update/test_decide_pr_action.sh test_pre_commit_autoupdate: if: github.event_name != 'schedule' || github.repository == 'Calysto/maintainer_tools' diff --git a/actions/poetry-lock-update/decide_pr_action.sh b/actions/poetry-lock-update/decide_pr_action.sh new file mode 100755 index 0000000..062e807 --- /dev/null +++ b/actions/poetry-lock-update/decide_pr_action.sh @@ -0,0 +1,35 @@ +#!/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 naturally +# falls through to creating a fresh one — no extra state to track. +set -euo pipefail + +BRANCH="$1" +TITLE="$2" +BODY="$3" +LABELS="$4" +DRY_RUN="$5" + +PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + +if [ -n "$PR_NUMBER" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "Would update PR #$PR_NUMBER (no new PR created)" + else + gh pr edit "$PR_NUMBER" --body "$BODY" + echo "Updated PR #$PR_NUMBER" + fi +else + DRY_RUN_FLAG="" + if [ "$DRY_RUN" = "true" ]; then + DRY_RUN_FLAG="--dry-run" + fi + # shellcheck disable=SC2086 + gh pr create \ + --title "$TITLE" \ + --body "$BODY" \ + --label "$LABELS" \ + --head "$BRANCH" \ + $DRY_RUN_FLAG +fi diff --git a/actions/poetry-lock-update/test_decide_pr_action.sh b/actions/poetry-lock-update/test_decide_pr_action.sh new file mode 100755 index 0000000..bfedf47 --- /dev/null +++ b/actions/poetry-lock-update/test_decide_pr_action.sh @@ -0,0 +1,87 @@ +#!/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_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 +} + +check_not_contains() { + local desc="$1" + local needle="$2" + local haystack="$3" + if echo "$haystack" | grep -qF -- "$needle"; then + echo "FAIL: $desc" + echo " expected NOT to find: $needle" + echo " in: $haystack" + FAIL=1 + else + echo "OK: $desc" + fi +} + +# Fake `gh` that logs every invocation to gh_calls.log and, for `pr list`, +# answers from a canned JSON response file the test sets up beforehand. +# This is not a general gh emulator — it only handles the exact call +# shapes decide_pr_action.sh makes. +FAKE_GH="$TMPDIR/gh" +cat > "$FAKE_GH" <> "$TMPDIR/gh_calls.log" +if [ "\$1" = "pr" ] && [ "\$2" = "list" ]; then + jq -r '.[0].number // empty' "$TMPDIR/pr_list_response.json" +fi +FAKE_GH_EOF +chmod +x "$FAKE_GH" +export PATH="$TMPDIR:$PATH" + +run_script() { + local pr_list_json="$1" + shift + echo "$pr_list_json" > "$TMPDIR/pr_list_response.json" + : > "$TMPDIR/gh_calls.log" + bash "$SCRIPT" "$@" > "$TMPDIR/output.log" 2>&1 || true +} + +# Case 1: no open PR -> should create +run_script '[]' "poetry-lock-update" "chore: update poetry.lock" "BODY1" "maintenance" "false" +CALLS=$(cat "$TMPDIR/gh_calls.log") +check_contains "no open PR: gh pr create is called" "pr create" "$CALLS" +check_contains "no open PR: create uses --head poetry-lock-update" "--head poetry-lock-update" "$CALLS" +check_not_contains "no open PR: gh pr edit is NOT called" "pr edit" "$CALLS" + +# Case 2: open PR #42 -> should edit, not create +run_script '[{"number": 42}]' "poetry-lock-update" "chore: update poetry.lock" "BODY2" "maintenance" "false" +CALLS=$(cat "$TMPDIR/gh_calls.log") +check_contains "open PR: gh pr edit 42 is called" "pr edit 42" "$CALLS" +check_not_contains "open PR: gh pr create is NOT called" "pr create" "$CALLS" + +# Case 3: open PR #42, dry-run -> should neither edit nor create, and say so +run_script '[{"number": 42}]' "poetry-lock-update" "chore: update poetry.lock" "BODY3" "maintenance" "true" +CALLS=$(cat "$TMPDIR/gh_calls.log") +OUTPUT=$(cat "$TMPDIR/output.log") +check_not_contains "open PR + dry-run: gh pr edit is NOT called" "pr edit" "$CALLS" +check_not_contains "open PR + dry-run: gh pr create is NOT called" "pr create" "$CALLS" +check_contains "open PR + dry-run: output mentions PR #42" "PR #42" "$OUTPUT" + +# Case 4: no open PR, dry-run -> should create with --dry-run +run_script '[]' "poetry-lock-update" "chore: update poetry.lock" "BODY4" "maintenance" "true" +CALLS=$(cat "$TMPDIR/gh_calls.log") +check_contains "no open PR + dry-run: gh pr create is called" "pr create" "$CALLS" +check_contains "no open PR + dry-run: create passes --dry-run" "--dry-run" "$CALLS" + +exit $FAIL From 28106556809782a13ed2198e3f0dab460cb47004 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 3 Aug 2026 12:01:50 -0500 Subject: [PATCH 3/5] fix: reuse a fixed branch so lock-update PRs don't duplicate --- actions/poetry-lock-update/action.yml | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/actions/poetry-lock-update/action.yml b/actions/poetry-lock-update/action.yml index 27788a9..08724ff 100644 --- a/actions/poetry-lock-update/action.yml +++ b/actions/poetry-lock-update/action.yml @@ -64,10 +64,6 @@ runs: exit 0 fi - # Generate random suffix for branch name - SUFFIX=$(openssl rand -hex 4) - FULL_BRANCH="${BRANCH}-${SUFFIX}" - # diff_lock.py needs tomllib (Python 3.11+) or its tomli fallback; # install tomli on older interpreters where neither is present. python3 -c "import tomllib" 2>/dev/null || pip install --quiet tomli @@ -82,20 +78,17 @@ runs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git checkout -B "$FULL_BRANCH" + git checkout -B "$BRANCH" git add poetry.lock git commit -m "chore: update poetry.lock" + + # Branch is exclusively bot-owned and rebuilt fresh from the base + # 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. if [ "$DRY_RUN" != "true" ]; then - git push origin "$FULL_BRANCH" + git push --force origin "$BRANCH" fi - DRY_RUN_FLAG="" - if [ "$DRY_RUN" = "true" ]; then - DRY_RUN_FLAG="--dry-run" - fi - # shellcheck disable=SC2086 - gh pr create \ - --title "chore: update poetry.lock" \ - --body "$BODY" \ - --label "$LABELS" \ - --head "$FULL_BRANCH" \ - $DRY_RUN_FLAG + + bash "${{ github.action_path }}/decide_pr_action.sh" \ + "$BRANCH" "chore: update poetry.lock" "$BODY" "$LABELS" "$DRY_RUN" From d65c4e2b23be396bd7b122a4f7569985ba6b647a Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 3 Aug 2026 12:43:18 -0500 Subject: [PATCH 4/5] docs: document poetry-lock-update PR de-duplication --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46e4633..53fff2a 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ ______________________________________________________________________ ### `poetry-lock-update` -Runs `poetry update` under a minimum-release-age cooldown and opens a pull request with the `poetry.lock` changes. Requires `base-setup` to run before this action. Optionally generates a GitHub App token for authenticated pushes. +Runs `poetry update` under a minimum-release-age cooldown and opens a pull request with the `poetry.lock` changes — or, if a lock-update PR is already open, force-pushes the refreshed lock file to it instead of opening a duplicate. Requires `base-setup` to run before this action. Optionally generates a GitHub App token for authenticated pushes. **Inputs** @@ -165,7 +165,7 @@ Runs `poetry update` under a minimum-release-age cooldown and opens a pull reque | `app-id` | No | `""` | GitHub App ID for authenticated pushes. Falls back to `github.token` if not provided. | | `app-private-key` | No | `""` | GitHub App private key for authenticated pushes. | | `min-release-age-days` | No | `"7"` | Minimum release age in days before Poetry's resolver will consider a version. | -| `branch` | No | `"poetry-lock-update"` | Branch name for the update pull request. | +| `branch` | No | `"poetry-lock-update"` | Branch name for the update pull request. Reused across runs — an existing open PR on this branch is updated in place rather than duplicated. | | `labels` | No | `"maintenance"` | Labels to apply to the pull request. | | `dry-run` | No | `"false"` | If `"true"`, passes `--dry-run` to `gh pr create` (no PR is actually opened). | From 6a01e81470be3bde47f7f9e64bf861a8ef7e6cd5 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 3 Aug 2026 13:34:07 -0500 Subject: [PATCH 5/5] fix: address final review findings (CI permissions, label reapply, docs, test coverage) --- .github/workflows/tests.yml | 3 +++ CLAUDE.md | 2 +- README.md | 4 ++-- actions/poetry-lock-update/decide_pr_action.sh | 2 +- actions/poetry-lock-update/test_decide_pr_action.sh | 2 ++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b7c892..c4aa83f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -123,6 +123,9 @@ jobs: test_poetry_lock_update: if: github.event_name != 'schedule' || github.repository == 'Calysto/maintainer_tools' name: Test Poetry Lock Update + permissions: + contents: read + pull-requests: read runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 21be432..9bde416 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ uses: calysto/maintainer_tools/actions/@v1 Each action lives in `actions//action.yml`. The actions are: - **`base-setup`** — Sets up Python (auto-detects minimum version from `pyproject.toml` if unspecified), Poetry, and `just` with OS-keyed cache. Must be called before `release`, `test-minimum-versions`, and `test-sdist`. -- **`poetry-lock-update`** — Runs `poetry update` under a minimum-release-age cooldown (`POETRY_SOLVER_MIN_RELEASE_AGE`, default 7 days) and opens a pull request with the `poetry.lock` diff. Requires a GitHub App (`APP_ID` / `APP_PRIVATE_KEY`) for authenticated pushes. Must be called after `base-setup`. +- **`poetry-lock-update`** — Runs `poetry update` under a minimum-release-age cooldown (`POETRY_SOLVER_MIN_RELEASE_AGE`, default 7 days) and opens a pull request with the `poetry.lock` diff, updating an existing open PR on the branch in place instead of opening a duplicate. Requires a GitHub App (`APP_ID` / `APP_PRIVATE_KEY`) for authenticated pushes. Must be called after `base-setup`. - **`enforce-label`** — Wraps `yogevbd/enforce-label-action`; requires one of: `bug`, `enhancement`, `dependencies`, `maintenance`, `documentation`. - **`release`** — Full release pipeline: bumps version via Poetry, generates and writes CHANGELOG.md, commits and pushes, creates GitHub release, then bumps to next `.dev` version using `actions/release/bump_dev.py`. Supports dry-run. Requires a GitHub App (`APP_ID` / `APP_PRIVATE_KEY`) for authenticated pushes. - **`test-minimum-versions`** — Rewrites `pyproject.toml` to pin all deps to their minimum declared versions, then runs the test suite. diff --git a/README.md b/README.md index 53fff2a..8ab9ddb 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,9 @@ Runs `poetry update` under a minimum-release-age cooldown and opens a pull reque | `app-id` | No | `""` | GitHub App ID for authenticated pushes. Falls back to `github.token` if not provided. | | `app-private-key` | No | `""` | GitHub App private key for authenticated pushes. | | `min-release-age-days` | No | `"7"` | Minimum release age in days before Poetry's resolver will consider a version. | -| `branch` | No | `"poetry-lock-update"` | Branch name for the update pull request. Reused across runs — an existing open PR on this branch is updated in place rather than duplicated. | +| `branch` | No | `"poetry-lock-update"` | Branch name for the update pull request. Reused across runs — an existing open PR on this branch is updated in place rather than duplicated. This branch is exclusively managed by this action and force-pushed on every run, so manual commits pushed to it will not survive. | | `labels` | No | `"maintenance"` | Labels to apply to the pull request. | -| `dry-run` | No | `"false"` | If `"true"`, passes `--dry-run` to `gh pr create` (no PR is actually opened). | +| `dry-run` | No | `"false"` | If `"true"`, no push or PR mutation happens: `gh pr create` runs with `--dry-run` when no PR is open, or a "would update" message prints when one is. | **Usage** diff --git a/actions/poetry-lock-update/decide_pr_action.sh b/actions/poetry-lock-update/decide_pr_action.sh index 062e807..348df6d 100755 --- a/actions/poetry-lock-update/decide_pr_action.sh +++ b/actions/poetry-lock-update/decide_pr_action.sh @@ -17,7 +17,7 @@ if [ -n "$PR_NUMBER" ]; then if [ "$DRY_RUN" = "true" ]; then echo "Would update PR #$PR_NUMBER (no new PR created)" else - gh pr edit "$PR_NUMBER" --body "$BODY" + gh pr edit "$PR_NUMBER" --body "$BODY" --add-label "$LABELS" echo "Updated PR #$PR_NUMBER" fi else diff --git a/actions/poetry-lock-update/test_decide_pr_action.sh b/actions/poetry-lock-update/test_decide_pr_action.sh index bfedf47..4fbb0ec 100755 --- a/actions/poetry-lock-update/test_decide_pr_action.sh +++ b/actions/poetry-lock-update/test_decide_pr_action.sh @@ -63,12 +63,14 @@ CALLS=$(cat "$TMPDIR/gh_calls.log") check_contains "no open PR: gh pr create is called" "pr create" "$CALLS" check_contains "no open PR: create uses --head poetry-lock-update" "--head poetry-lock-update" "$CALLS" check_not_contains "no open PR: gh pr edit is NOT called" "pr edit" "$CALLS" +check_contains "pr list uses --state open" "--state open" "$CALLS" # Case 2: open PR #42 -> should edit, not create run_script '[{"number": 42}]' "poetry-lock-update" "chore: update poetry.lock" "BODY2" "maintenance" "false" CALLS=$(cat "$TMPDIR/gh_calls.log") check_contains "open PR: gh pr edit 42 is called" "pr edit 42" "$CALLS" check_not_contains "open PR: gh pr create is NOT called" "pr create" "$CALLS" +check_contains "open PR: edit re-applies label" "--add-label maintenance" "$CALLS" # Case 3: open PR #42, dry-run -> should neither edit nor create, and say so run_script '[{"number": 42}]' "poetry-lock-update" "chore: update poetry.lock" "BODY3" "maintenance" "true"