diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cd15bf0..5194934 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,7 @@ updates: - "/actions/*" schedule: interval: "weekly" + day: "tuesday" cooldown: default-days: 7 groups: @@ -18,6 +19,7 @@ updates: directory: "/" schedule: interval: "weekly" + day: "tuesday" cooldown: default-days: 7 # Security updates only; routine lock refreshes run in .github/workflows/lock-update.yml diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..df42550 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,28 @@ +# .github/workflows/dependabot-auto-merge.yml +name: Dependabot auto-merge + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Approve and enable auto-merge for patch and minor updates + if: steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.update-type == 'version-update:semver-minor' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash "$PR_URL" diff --git a/.github/workflows/lock-update.yml b/.github/workflows/lock-update.yml index b87e472..b1c2290 100644 --- a/.github/workflows/lock-update.yml +++ b/.github/workflows/lock-update.yml @@ -23,3 +23,4 @@ jobs: with: app-id: ${{ vars.APP_ID }} app-private-key: ${{ secrets.APP_PRIVATE_KEY }} + auto-merge: "true" diff --git a/.github/workflows/pre-commit-autoupdate.yml b/.github/workflows/pre-commit-autoupdate.yml index 72ac867..6059cd0 100644 --- a/.github/workflows/pre-commit-autoupdate.yml +++ b/.github/workflows/pre-commit-autoupdate.yml @@ -4,7 +4,7 @@ name: Pre-commit autoupdate on: workflow_dispatch: schedule: - - cron: '0 9 * * 1' # Every Monday at 9am + - cron: '0 9 * * 3' # Every Wednesday at 9am permissions: pull-requests: write @@ -22,3 +22,4 @@ jobs: with: app-id: ${{ vars.APP_ID }} app-private-key: ${{ secrets.APP_PRIVATE_KEY }} + auto-merge: "true" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c13033b..f14b61c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -109,6 +109,8 @@ jobs: 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 + - name: Test auto_merge.sh + run: bash actions/common/test_auto_merge.sh - name: Test stage_update.sh run: bash actions/poetry-lock-update/test_stage_update.sh - name: Test resolve_lock.py diff --git a/CLAUDE.md b/CLAUDE.md index e7b05fd..24b6bc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,11 +22,11 @@ uses: calysto/maintainer_tools/actions/@v1 ### Actions -Each action lives in `actions//action.yml`. The actions are: +Each action lives in `actions//action.yml`. Shared shell helpers used by multiple actions live in `actions/common/` (e.g. `auto_merge.sh`). The actions are: - **`base-setup`** — Sets up Python (auto-detects minimum version from `pyproject.toml` if unspecified), Poetry, and `just` with OS-keyed cache. Poetry defaults to the version pinned by the poetry hook in `.pre-commit-config.yaml`. 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, updating an existing open PR on the branch in place instead of opening a duplicate. Also owns the Poetry version: it bumps the poetry hook in `.pre-commit-config.yaml` to the newest cooldown-eligible release (`prek update --repo`), reinstalls it, and regenerates the lock, so the version and lock always move together. Requires a GitHub App (`APP_ID` / `APP_PRIVATE_KEY`) for authenticated pushes. Must be called after `base-setup`. -- **`pre-commit-autoupdate`** — Runs `prek update` under a cooldown and opens a PR with the changes. Excludes the poetry hook by default (owned by `poetry-lock-update`). +- **`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. Also owns the Poetry version: it bumps the poetry hook in `.pre-commit-config.yaml` to the newest cooldown-eligible release (`prek update --repo`), reinstalls it, and regenerates the lock, so the version and lock always move together. Requires a GitHub App (`APP_ID` / `APP_PRIVATE_KEY`) for authenticated pushes. Must be called after `base-setup`. Supports an opt-in `auto-merge` input that approves the PR (with the workflow token, since the App can't approve its own PR) and enables squash auto-merge. +- **`pre-commit-autoupdate`** — Runs `prek update` under a cooldown and opens a PR with the changes. Excludes the poetry hook by default (owned by `poetry-lock-update`). Supports an opt-in `auto-merge` input that approves the PR (with the workflow token, since the App can't approve its own PR) and enables squash auto-merge. - **`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 10a2c23..3a79070 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ The poetry hook is always excluded — in whatever URL form the config uses (`ht | `exclude-repos` | No | `""` | Space-separated list of additional hook repositories to leave untouched (passed through as `--exclude-repo`). The configured poetry hook is always excluded, in whatever URL form it uses. | | `branch` | No | `"pre-commit-autoupdate"` | Branch name for the autoupdate pull request. | | `labels` | No | `"maintenance"` | Labels to apply to the pull request. | +| `auto-merge` | No | `"false"` | If `"true"`, approve the pull request and enable auto-merge (squash) so it merges once required checks pass. The PR is authored by the GitHub App, which cannot approve its own PR, so approval is submitted with the workflow token — the caller must grant the workflow `pull-requests: write`. Requires "Allow auto-merge" to be enabled in the repository settings. | | `dry-run` | No | `"false"` | If `"true"`, passes `--dry-run` to `gh pr create` (no PR is actually opened). | **Usage** @@ -177,6 +178,7 @@ The pull request body lists the Poetry version bump (if any) under a `## Poetry | `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. 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. | +| `auto-merge` | No | `"false"` | If `"true"`, approve the pull request and enable auto-merge (squash) so it merges once required checks pass. The PR is authored by the GitHub App, which cannot approve its own PR, so approval is submitted with the workflow token — the caller must grant the workflow `pull-requests: write`. Requires "Allow auto-merge" to be enabled in the repository settings. | | `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/common/auto_merge.sh b/actions/common/auto_merge.sh new file mode 100755 index 0000000..e118a0a --- /dev/null +++ b/actions/common/auto_merge.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Approve a bot-authored pull request and enable squash auto-merge on it. +# +# The GitHub App that opens these pull requests cannot approve its own PR +# ("Can not approve your own pull request"), so the approval is submitted with +# the workflow token instead, which is a different actor. This requires the +# caller's workflow to grant the default token `pull-requests: write`. +# +# Usage: auto_merge.sh +# +# Reads the following environment variables: +# AUTO_MERGE - "true" to act; any other value is a no-op +# DRY_RUN - "true" to skip all mutations +# APP_TOKEN - app installation token; when empty, approval is skipped +# (the workflow token would be the PR author) +# WORKFLOW_TOKEN - workflow token used to submit the approval +set -euo pipefail + +BRANCH="$1" + +if [ "${AUTO_MERGE:-false}" != "true" ] || [ "${DRY_RUN:-false}" = "true" ]; then + exit 0 +fi + +if [ -n "${APP_TOKEN:-}" ]; then + GH_TOKEN="${WORKFLOW_TOKEN:-}" gh pr review "$BRANCH" --approve +else + echo "No app token configured; skipping approval" +fi + +# The branch (and its PR) may be reused across runs, so only enable auto-merge +# when it is not already enabled, since re-enabling it errors. A failed lookup +# must fail the step rather than silently skipping the merge. +AUTO_MERGE_REQUEST=$(gh pr view "$BRANCH" --json autoMergeRequest --jq '.autoMergeRequest') || exit $? +if [ "$AUTO_MERGE_REQUEST" = "null" ]; then + gh pr merge "$BRANCH" --auto --squash --delete-branch +fi diff --git a/actions/common/test_auto_merge.sh b/actions/common/test_auto_merge.sh new file mode 100755 index 0000000..f7b0947 --- /dev/null +++ b/actions/common/test_auto_merge.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/auto_merge.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 the effective GH_TOKEN and arguments for every call. For +# `pr view` it prints a canned autoMergeRequest value, or fails when the +# pr_view_fail sentinel exists. This is not a general gh emulator — it only +# handles the exact call shapes auto_merge.sh makes. +FAKE_GH="$TMPDIR/gh" +cat > "$FAKE_GH" <> "$TMPDIR/gh_calls.log" +if [ "\$1" = "pr" ] && [ "\$2" = "view" ]; then + if [ -f "$TMPDIR/pr_view_fail" ]; then + exit 1 + fi + cat "$TMPDIR/pr_view_response" +fi +FAKE_GH_EOF +chmod +x "$FAKE_GH" +export PATH="$TMPDIR:$PATH" + +# run +run() { + echo "$4" > "$TMPDIR/pr_view_response" + : > "$TMPDIR/gh_calls.log" + rm -f "$TMPDIR/pr_view_fail" + AUTO_MERGE="$1" DRY_RUN="$2" APP_TOKEN="$3" WORKFLOW_TOKEN="workflow-token" \ + bash "$SCRIPT" "poetry-lock-update" > "$TMPDIR/output.log" 2>&1 +} + +# Case 1: disabled -> no gh calls at all +run "false" "false" "app-token" "null" +CALLS=$(cat "$TMPDIR/gh_calls.log") +if [ -z "$CALLS" ]; then + echo "OK: disabled: no gh calls" +else + echo "FAIL: disabled: expected no gh calls, got: $CALLS" + FAIL=1 +fi + +# Case 2: dry-run -> no gh calls at all +run "true" "true" "app-token" "null" +CALLS=$(cat "$TMPDIR/gh_calls.log") +if [ -z "$CALLS" ]; then + echo "OK: dry-run: no gh calls" +else + echo "FAIL: dry-run: expected no gh calls, got: $CALLS" + FAIL=1 +fi + +# Case 3: opt-in, not yet enabled -> approve with the workflow token, then merge +run "true" "false" "app-token" "null" +CALLS=$(cat "$TMPDIR/gh_calls.log") +check_contains "opt-in: approves with the workflow token" "GH_TOKEN=workflow-token :: pr review poetry-lock-update --approve" "$CALLS" +check_contains "opt-in: enables squash auto-merge" "pr merge poetry-lock-update --auto --squash --delete-branch" "$CALLS" + +# Case 4: already enabled -> approve, but do not re-enable +run "true" "false" "app-token" '{"enabledBy":{"login":"x"}}' +CALLS=$(cat "$TMPDIR/gh_calls.log") +check_contains "already enabled: still approves" "pr review poetry-lock-update --approve" "$CALLS" +check_not_contains "already enabled: does NOT re-enable auto-merge" "pr merge" "$CALLS" + +# Case 5: no app token -> skip approval, still enable auto-merge +run "true" "false" "" "null" +CALLS=$(cat "$TMPDIR/gh_calls.log") +check_not_contains "no app token: does NOT approve" "pr review" "$CALLS" +check_contains "no app token: still enables auto-merge" "pr merge poetry-lock-update --auto --squash --delete-branch" "$CALLS" + +# Case 6: gh pr view fails -> step fails, no merge attempted +echo "null" > "$TMPDIR/pr_view_response" +: > "$TMPDIR/gh_calls.log" +touch "$TMPDIR/pr_view_fail" +set +e +AUTO_MERGE="true" DRY_RUN="false" APP_TOKEN="app-token" WORKFLOW_TOKEN="workflow-token" \ + bash "$SCRIPT" "poetry-lock-update" > "$TMPDIR/output.log" 2>&1 +RC=$? +set -e +CALLS=$(cat "$TMPDIR/gh_calls.log") +if [ "$RC" -ne 0 ]; then + echo "OK: lookup failure: non-zero exit ($RC)" +else + echo "FAIL: lookup failure: expected non-zero exit" + FAIL=1 +fi +check_not_contains "lookup failure: does NOT enable auto-merge" "pr merge" "$CALLS" + +exit $FAIL diff --git a/actions/poetry-lock-update/action.yml b/actions/poetry-lock-update/action.yml index 1530fed..d43788e 100644 --- a/actions/poetry-lock-update/action.yml +++ b/actions/poetry-lock-update/action.yml @@ -22,6 +22,10 @@ inputs: required: false default: "maintenance" description: "Labels to apply to the pull request" + auto-merge: + required: false + default: "false" + description: "If 'true', enable auto-merge (squash) so the pull request merges once required checks pass" dry-run: required: false default: "false" @@ -88,8 +92,11 @@ runs: shell: bash env: GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + WORKFLOW_TOKEN: ${{ github.token }} BRANCH: ${{ inputs.branch }} LABELS: ${{ inputs.labels }} + AUTO_MERGE: ${{ inputs.auto-merge }} DRY_RUN: ${{ inputs.dry-run }} OLD_LOCK: ${{ runner.temp }}/poetry.lock.before WAIVED: ${{ steps.resolve.outputs.waived }} @@ -144,3 +151,5 @@ runs: bash "${{ github.action_path }}/decide_pr_action.sh" \ "$BRANCH" "chore: update poetry.lock" "$BODY" "$LABELS" "$DRY_RUN" + + bash "${{ github.action_path }}/../common/auto_merge.sh" "$BRANCH" diff --git a/actions/pre-commit-autoupdate/action.yml b/actions/pre-commit-autoupdate/action.yml index a7a7375..2e2fe6b 100644 --- a/actions/pre-commit-autoupdate/action.yml +++ b/actions/pre-commit-autoupdate/action.yml @@ -30,6 +30,10 @@ inputs: required: false default: "maintenance" description: "Labels to apply to the pull request" + auto-merge: + required: false + default: "false" + description: "If 'true', enable auto-merge (squash) so the pull request merges once required checks pass" dry-run: required: false default: "false" @@ -84,8 +88,11 @@ runs: shell: bash env: GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + WORKFLOW_TOKEN: ${{ github.token }} BRANCH: ${{ inputs.branch }} LABELS: ${{ inputs.labels }} + AUTO_MERGE: ${{ inputs.auto-merge }} DRY_RUN: ${{ inputs.dry-run }} run: | if git diff --quiet; then @@ -126,3 +133,5 @@ runs: --label "$LABELS" \ --head "$FULL_BRANCH" \ $DRY_RUN_FLAG + + bash "${{ github.action_path }}/../common/auto_merge.sh" "$FULL_BRANCH"