name: Validate and auto-merge bot PRs
on:
workflow_dispatch:
pull_request:
branches:
- "*"
permissions:
contents: read
jobs:
dependabot:
name: Validate and auto-merge bot PRs
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: read
actions: write
if: |
github.event.pull_request.user.login == 'dependabot[bot]' ||
github.event.pull_request.user.login == 'butlergroup-automerge-token-issuer[bot]' ||
github.event.pull_request.user.login == 'github-actions[bot]'
steps:
- name: Harden the runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411
with:
egress-policy: audit
- name: Fetch Dependabot metadata
if: github.event.pull_request.user.login == 'dependabot[bot]'
id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch bot PR metadata
id: bot-metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BOT_LOGIN: ${{ github.event.pull_request.user.login }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
labels=$(gh api "repos/$REPO/issues/$PR_NUMBER/labels" \
--jq '[.[].name] | join(",")')
if [[ "$BOT_LOGIN" == "dependabot[bot]" ]]; then
bot_type="dependabot"
elif [[ "$BOT_LOGIN" == "github-actions[bot]" ]]; then
bot_type="github-actions"
elif [[ "$BOT_LOGIN" == "butlergroup-automerge-token-issuer[bot]" ]]; then
bot_type="github-app"
else
bot_type="unknown"
fi
{
echo "bot-login=$BOT_LOGIN"
echo "pr-title=$PR_TITLE"
echo "pr-branch=$PR_BRANCH"
echo "labels=$labels"
echo "bot-type=$bot_type"
} >> "$GITHUB_OUTPUT"
- name: Wait for all checks to complete successfully
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Timeout after 30 minutes
timeout_seconds=1800
start_time=$(date +%s)
# Checks to ignore
EXCLUDED_PATTERNS=(
"Validate and auto-merge bot PRs"
"Greet First-Time Contributors"
"code/snyk"
)
echo "Waiting for checks on commit: $SHA"
while true; do
now=$(date +%s)
elapsed=$((now - start_time))
if [[ $elapsed -ge $timeout_seconds ]]; then
echo "Timed out waiting for checks."
exit 1
fi
echo "Fetching check runs..."
checks=$(gh api \
"repos/$REPO/commits/$SHA/check-runs" \
--jq '.check_runs[] | [
.name,
.status,
.conclusion
] | @tsv')
if [[ -z "$checks" ]]; then
echo "No checks registered yet..."
sleep 15
continue
fi
pending=0
failed=0
actionable_checks=0
while IFS=$'\t' read -r name status conclusion; do
skip=false
for pattern in "${EXCLUDED_PATTERNS[@]}"; do
if [[ "$name" == *"$pattern"* ]]; then
echo "Skipping excluded check: $name"
skip=true
break
fi
done
if [[ "$skip" == true ]]; then
continue
fi
echo "Check: $name"
echo " Status: $status"
echo " Conclusion: $conclusion"
actionable_checks=$((actionable_checks + 1))
#
# Pending states
#
if [[ "$status" != "completed" ]]; then
pending=1
continue
fi
#
# Failed states
#
case "$conclusion" in
success|neutral|skipped)
;;
*)
echo "Check failed: $name"
failed=1
;;
esac
done <<< "$checks"
#
# Prevent accidental merges if no real checks exist
#
if [[ $actionable_checks -eq 0 ]]; then
echo "No actionable checks found yet..."
pending=1
fi
#
# Fail immediately if any check failed
#
if [[ $failed -eq 1 ]]; then
echo "One or more checks failed."
exit 1
fi
#
# Success condition
#
if [[ $pending -eq 0 ]]; then
echo "All checks completed successfully."
#
# Extra stabilization delay to avoid race conditions
#
echo "Waiting 20 seconds for GitHub state stabilization..."
sleep 20
break
fi
echo "Checks still pending..."
sleep 15
done
- name: Verify PR mergeability
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
set -euo pipefail
for _ in {1..20}; do
state=$(gh pr view "$PR_URL" \
--json mergeable \
--jq '.mergeable')
echo "Mergeable state: $state"
if [[ "$state" == "MERGEABLE" ]]; then
exit 0
fi
if [[ "$state" == "CONFLICTING" ]]; then
echo "PR has merge conflicts."
exit 1
fi
sleep 10
done
echo "PR never became mergeable."
exit 1
- name: Verify GitHub App secret availability
shell: bash
env:
APP_ID: ${{ vars.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
run: |
set -euo pipefail
if [[ -z "$APP_ID" ]]; then
echo "::error::APP_ID is empty"
exit 1
fi
if [[ -z "$APP_PRIVATE_KEY" ]]; then
echo "::error::APP_PRIVATE_KEY is empty/unavailable in this workflow context"
exit 1
fi
if ! grep -q "BEGIN .*PRIVATE KEY" <<< "$APP_PRIVATE_KEY"; then
echo "::error::APP_PRIVATE_KEY does not look like a PEM private key"
exit 1
fi
echo "GitHub App inputs are present"
- name: Generate GitHub App installation token manually
id: app-token
shell: bash
env:
APP_ID: ${{ vars.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
private_key_file="$(mktemp)"
trap 'rm -f "$private_key_file"' EXIT
printf '%s\n' "$APP_PRIVATE_KEY" > "$private_key_file"
now="$(date +%s)"
iat="$((now - 60))"
exp="$((now + 540))"
b64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
header="$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)"
payload="$(printf '{"iat":%s,"exp":%s,"iss":"%s"}' "$iat" "$exp" "$APP_ID" | b64url)"
unsigned_token="${header}.${payload}"
signature="$(
printf '%s' "$unsigned_token" |
openssl dgst -sha256 -sign "$private_key_file" -binary |
b64url
)"
jwt="${unsigned_token}.${signature}"
owner="${REPO%%/*}"
repo_name="${REPO#*/}"
echo "Looking up GitHub App installation for $REPO"
installation_json="$(
curl -sS \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $jwt" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$REPO/installation"
)"
installation_id="$(jq -r '.id // empty' <<< "$installation_json")"
if [[ -z "$installation_id" ]]; then
echo "Repo installation lookup did not return an installation ID."
echo "Trying owner-level installation lookup for $owner"
installation_json="$(
curl -sS \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $jwt" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$owner/installation"
)"
installation_id="$(jq -r '.id // empty' <<< "$installation_json")"
fi
if [[ -z "$installation_id" ]]; then
echo "::error::Could not determine GitHub App installation ID for $REPO. Confirm the app is installed on owner '$owner' and has access to repo '$repo_name'."
echo "$installation_json" | jq -r '.message // empty'
exit 1
fi
token_json="$(
curl -sS \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $jwt" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/app/installations/$installation_id/access_tokens" \
-d "$(jq -nc \
--arg repo "$repo_name" \
'{
repositories: [$repo],
permissions: {
contents: "write",
pull_requests: "write",
checks: "read",
actions: "write"
}
}')"
)"
token="$(jq -r '.token // empty' <<< "$token_json")"
if [[ -z "$token" ]]; then
echo "::error::GitHub App installation token was empty"
echo "$token_json" | jq -r '.message // empty'
exit 1
fi
echo "::add-mask::$token"
echo "token=$token" >> "$GITHUB_OUTPUT"
- name: Merge bot PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
set -euo pipefail
gh pr view "$PR_NUMBER" \
--repo "$REPO" \
--json body \
--jq '.body' > pr-body.md
gh pr merge "$PR_NUMBER" \
--repo "$REPO" \
--merge \
--delete-branch \
--subject "$PR_TITLE" \
--body-file pr-body.md
- name: Notify on failure
if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `❌ Bot PR auto-merge failed
PR: ${context.payload.pull_request.html_url}
Workflow:
${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
})