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
142 changes: 0 additions & 142 deletions .claude/skills/ship/SKILL.md

This file was deleted.

203 changes: 203 additions & 0 deletions .github/workflows/auto-merge-bot-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# -----------------------------------------------------------------
# Auto-merge bot PRs
#
# Squash-merges Dependabot PRs and the weekly pre-commit hook update
# PRs once every check on the PR is green and none is pending.
#
# Why admin bypass: the pre-commit PRs are authored with the repo
# owner's PAT, and GitHub refuses self-approval, so a review-based
# auto-merge can never satisfy the CODEOWNERS rule. Branch protection
# is not enforced on admins, so the owner's PAT merges directly.
#
# Safety: never touches drafts, fork PRs, PRs from other authors,
# PRs with conflicts, PRs with no registered checks, or PRs with any
# failing or pending check. A PR that is behind main is updated and
# picked up on the next run.
#
# Requires: PRE_COMMIT_PAT secret (fine-grained PAT of the repo owner
# with contents, pull requests and workflows write access).
# -----------------------------------------------------------------
name: Auto-merge bot PRs
run-name: "Auto-merge bot PRs (${{ github.event_name }})"

on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:

permissions: {}

concurrency:
group: auto-merge-bot-prs
cancel-in-progress: false

jobs:
merge:
name: Merge green bot PRs
runs-on: ubuntu-latest
timeout-minutes: 15
# zizmor's secrets-outside-env audit requires secrets to be read inside a
# job environment. "automation" carries no protection rules and GitHub
# creates it on first use.
environment: automation
Comment thread
gamaware marked this conversation as resolved.
Resolved
steps:
- name: Merge eligible PRs
env:
GH_TOKEN: ${{ secrets.PRE_COMMIT_PAT }}
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail

if [ -z "$GH_TOKEN" ]; then
echo "::error::PRE_COMMIT_PAT secret is not set on this repository"
exit 1
fi

# Candidate PRs: Dependabot, or the weekly pre-commit update branch
# authored by the repo owner (the PAT owner). Fork PRs are excluded
# because anyone can name a fork branch to match the prefix.
# Capture the list first so a gh failure aborts the run instead of
# being mistaken for "no open PRs".
pr_list=$(
gh pr list --repo "$REPO" --state open --limit 100 \
--json number,author,headRefName,baseRefName,headRefOid,isDraft,isCrossRepository,headRepositoryOwner \
--jq '.[]
| select(.isDraft | not)
| select(.isCrossRepository | not)
| select(.headRepositoryOwner.login == env.OWNER)
| select(.baseRefName == "main")
| select(
((.author.login | sub("^app/"; "")) == "dependabot")
Comment thread
gamaware marked this conversation as resolved.
Resolved
or (
(.headRefName | startswith("chore/update-pre-commit-hooks"))
and (.author.login == env.OWNER)
)
)
| "\(.number) \(.headRefOid)"'
)
mapfile -t candidates <<< "$pr_list"
if [ "${#candidates[@]}" -eq 1 ] && [ -z "${candidates[0]}" ]; then
candidates=()
fi

if [ "${#candidates[@]}" -eq 0 ]; then
echo "No eligible bot PRs open."
exit 0
fi

merged=0
skipped=0

for entry in "${candidates[@]}"; do
number="${entry%% *}"
head_sha="${entry##* }"
if [[ ! "$head_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::PR #$number: missing head SHA in '$entry', skipping"
skipped=$((skipped + 1))
continue
fi

if ! state_line=$(gh pr view "$number" --repo "$REPO" \
--json mergeStateStatus,reviewDecision \
--jq '"\(.mergeStateStatus) \(.reviewDecision)"'); then
echo "PR #$number: could not read merge state, skipping"
skipped=$((skipped + 1))
continue
fi
state="${state_line%% *}"
review="${state_line##* }"

# Admin bypass must never override a reviewer who asked for changes.
if [ "$review" = "CHANGES_REQUESTED" ]; then
echo "PR #$number: changes requested by a reviewer, skipping"
skipped=$((skipped + 1))
continue
fi

case "$state" in
DIRTY)
echo "PR #$number: merge conflicts, skipping"
skipped=$((skipped + 1))
continue
;;
BEHIND)
echo "PR #$number: behind base, requesting branch update"
gh pr update-branch "$number" --repo "$REPO" \
|| echo "::warning::PR #$number: branch update failed, will retry next run"
skipped=$((skipped + 1))
continue
;;
CLEAN|BLOCKED|UNSTABLE|HAS_HOOKS)
;;
*)
# UNKNOWN (mergeability still being computed) or anything new.
echo "PR #$number: merge state $state, skipping"
skipped=$((skipped + 1))
continue
;;
esac

# gh pr checks exits non-zero on API errors or when no checks exist
# (with --json it exits 0 for failing or pending checks). Keep the
# output, drop the exit code, and treat empty output as "no checks"
# so the guard below skips.
checks_err="${RUNNER_TEMP:-/tmp}/pr-checks-$number.err"
summary=$(gh pr checks "$number" --repo "$REPO" --json bucket \
--jq '[
length,
([.[] | select(.bucket == "fail" or .bucket == "cancel")] | length),
([.[] | select(.bucket == "pending")] | length),
([.[] | select(.bucket == "pass")] | length)
] | @tsv' 2>"$checks_err") || true
if [ -z "$summary" ]; then
# Distinguish a real API/auth failure from "no checks reported".
if [ -s "$checks_err" ] && ! grep -qi "no checks" "$checks_err"; then
echo "::warning::PR #$number: gh pr checks failed: $(head -n 1 "$checks_err")"
fi
summary=$'0\t0\t0\t0'
fi
IFS=$'\t' read -r total fail pending pass <<< "$summary"

if [ "$fail" -gt 0 ]; then
echo "PR #$number: $fail failing check(s), skipping"
skipped=$((skipped + 1))
continue
fi
if [ "$pending" -gt 0 ]; then
echo "PR #$number: $pending pending check(s), skipping"
skipped=$((skipped + 1))
continue
fi
# Never merge on an empty check list: a brand-new PR may not have
# registered its checks yet, and a held fork run has none at all.
if [ "$total" -eq 0 ]; then
echo "PR #$number: no checks registered, skipping"
skipped=$((skipped + 1))
continue
fi
# Skipped checks are not failures, but they are not evidence either:
# require at least one check that actually ran and passed.
if [ "$pass" -eq 0 ]; then
echo "PR #$number: $total check(s) registered but none passed, skipping"
skipped=$((skipped + 1))
continue
fi

echo "PR #$number: $pass of $total check(s) passed, none failing or pending, merging"
# Bind the merge to the head captured before the checks were
# inspected; if the head moved since then, the merge is refused.
if gh pr merge "$number" --repo "$REPO" --squash --admin --delete-branch \
--match-head-commit "$head_sha"; then
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
merged=$((merged + 1))
# main has moved: stop here so every remaining candidate is
# re-evaluated against the new base on the next run.
echo "Merged one PR; remaining candidates are re-evaluated next run."
break
else
echo "::warning::PR #$number: merge failed"
skipped=$((skipped + 1))
fi
done

echo "Merged: $merged | Skipped: $skipped"
42 changes: 0 additions & 42 deletions .github/workflows/auto-merge-dependabot.yml

This file was deleted.

Loading
Loading