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
5 changes: 5 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -121,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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ uses: calysto/maintainer_tools/actions/<name>@v1
Each action lives in `actions/<name>/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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -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. |
| `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**

Expand Down
27 changes: 10 additions & 17 deletions actions/poetry-lock-update/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
35 changes: 35 additions & 0 deletions actions/poetry-lock-update/decide_pr_action.sh
Original file line number Diff line number Diff line change
@@ -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" --add-label "$LABELS"
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
89 changes: 89 additions & 0 deletions actions/poetry-lock-update/test_decide_pr_action.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/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" <<FAKE_GH_EOF
#!/usr/bin/env bash
echo "\$*" >> "$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"
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"
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
Loading