Skip to content
124 changes: 110 additions & 14 deletions .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Dependabot auto-approve and auto-merge
name: Bot PR auto-approve and auto-merge

on:
pull_request_target:
Expand All @@ -9,32 +9,128 @@ permissions:
contents: write

jobs:
dependabot:
automerge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
# Trusted automation only. Keyed on the PR author so the job still runs, and
# can revoke, when someone else pushes to a bot PR. Keep in sync across repos.
if: contains(fromJSON('["dependabot[bot]", "aikido-autofix[bot]"]'), github.event.pull_request.user.login)

steps:
- name: Checkout Repo
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Check the PR was pushed by its author
id: trust
run: |
# A later push by anyone else (e.g. a collaborator with write access)
# must not inherit an approval or armed auto-merge given to the bot.
if [ "$ACTOR" = "$AUTHOR" ]; then
echo "trusted=true" >> "$GITHUB_OUTPUT"
else
echo "trusted=false" >> "$GITHUB_OUTPUT"
fi
env:
ACTOR: ${{ github.actor }}
AUTHOR: ${{ github.event.pull_request.user.login }}

- name: Install GitHub CLI
# A green CI run does not prove a transitive bump is safe: the repo's own
# tests never exercise how the intermediate package uses the changed API.
# Anything matching below is neither approved nor auto-merged, so it cannot
# satisfy the required-review count and a human has to sign it off.
- name: Assess dependency risk
id: risk
if: steps.trust.outputs.trusted == 'true'
run: |
sudo apt-get update
sudo apt-get install -y gh
FILES=$(gh api "repos/$REPO/pulls/$PR_NUMBER/files" --paginate)
manifests() {
printf '%s' "$FILES" | jq -r ".[] | select(.filename|test(\"$1\")) | .patch // \"\""
}
REASON=""

# 1. The bot names major bumps in its own title.
if printf '%s' "$PR_TITLE" | grep -qiE 'major version upgrade'; then
REASON="major version upgrade"
fi

# 2. A forced transitive pin in a JS manifest (resolutions/overrides/glob).
if [ -z "$REASON" ] && manifests 'package\\.json$' \
| grep -qE '^[ +-].*"(resolutions|overrides)"[[:space:]]*:|^\+[[:space:]]*"\*\*/'; then
REASON="forced transitive override"
fi

# 3. A Go major bump.
if [ -z "$REASON" ] && manifests 'go\\.mod$' | grep -qE '^\+.*\+incompatible'; then
REASON="go major (+incompatible) bump"
fi

- name: Authenticate gh
run: echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token
# 4. A bulk sweep across many packages at once.
N=$(manifests '(go\\.mod|package\\.json)$' \
| grep -cE '^\+[[:space:]]+([a-zA-Z0-9./_-]+ v[0-9]|"[^"]+"[[:space:]]*:)' || true)
if [ -z "$REASON" ] && [ "${N:-0}" -gt 6 ]; then
REASON="bulk sweep ($N dependency lines changed)"
fi

- name: Approve dependabot PRs
# 5. A wide lockfile rewrite means many transitive packages moved,
# even when the manifest diff looks small.
CHURN=$(printf '%s' "$FILES" \
| jq '[.[] | select(.filename|test("(yarn\\.lock|package-lock\\.json|go\\.sum|pnpm-lock\\.yaml)$")) | .additions + .deletions] | add // 0')
if [ -z "$REASON" ] && [ "${CHURN:-0}" -gt 600 ]; then
REASON="wide lockfile rewrite ($CHURN lines)"
fi

# 6. Aikido only fixes dependencies. Any other touched file is not what
# the bot normally produces, so a human has to look at it.
if [ -z "$REASON" ] && [ "$GITHUB_ACTOR" = "aikido-autofix[bot]" ]; then
OTHER=$(printf '%s' "$FILES" | jq -r '.[].filename | select((split("/")[-1] | test("^(package\\.json|yarn\\.lock|package-lock\\.json|pnpm-lock\\.yaml|go\\.mod|go\\.sum|requirements[^/]*\\.(txt|in))$")) | not)' | head -3 | tr '\n' ' ')
if [ -n "$OTHER" ]; then
REASON="aikido PR touches non-dependency files: $OTHER"
fi
fi

if [ -n "$REASON" ]; then
echo "risky=true" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "::warning::Not auto-merging: $REASON"
else
echo "risky=false" >> "$GITHUB_OUTPUT"
fi
env:
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Approve bot PR
if: steps.risk.outputs.risky == 'false'
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Merge for dependabot PRs
- name: Enable auto-merge for bot PR
if: steps.risk.outputs.risky == 'false'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Hold bot PR for human review
if: steps.trust.outputs.trusted == 'false' || steps.risk.outputs.risky == 'true'
run: |
# An earlier run may have approved and armed this PR while it still
# looked routine, so undo both before flagging it.
gh pr merge --disable-auto "$PR_URL" || true

gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" \
--jq '.[] | select(.state == "APPROVED" and .user.login == "github-actions[bot]") | .id' \
| while read -r id; do
gh api -X PUT "repos/$REPO/pulls/$PR_NUMBER/reviews/$id/dismissals" \
-f message="Withdrawn: $REASON" >/dev/null || true
done

gh pr edit "$PR_URL" --add-label needs-human
gh pr comment "$PR_URL" --body \
"Held for human review: **$REASON**. This PR was deliberately **not** approved and auto-merge is off; any earlier approval was withdrawn. It needs a human to review and merge it."
env:
PR_URL: ${{ github.event.pull_request.html_url }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REASON: ${{ steps.risk.outputs.reason || format('{0} pushed to this PR, not the bot that opened it', github.actor) }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Loading