Dependabot opens a PR. CI is green. Nobody clicks approve, so the PR waits — and your package.json quietly accumulates 30 stale bumps. Or the team auto-merges everything and a major version lands in production with a breaking change nobody read. Both are failure modes of the same missing step: a decision about this update.
The fix is a gate with an actual rule: patches and minors with passing CI merge automatically; majors and grouped updates go to a human — with the changelog, the version jump, and the CI status attached.
The pattern: tier the risk, then decide
| Update | Version jump | Gate |
|---|---|---|
| Patch | 1.2.3 → 1.2.4 | Auto-approve + merge when CI passes |
| Minor | 1.2.3 → 1.3.0 | Auto-approve + merge when CI passes |
| Major | 1.2.3 → 2.0.0 | Ask a human — breaking changes expected |
| Grouped PR | multiple packages | Ask a human — changes are bundled |
Step 1: auto-approve the safe updates
The workflow approves bot PRs only when the version jump is safe and CI is green:
name: dependency-approval
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
checks: read
jobs:
gate:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Wait for CI checks
uses: lewagon/[email protected]
with:
ref: ${{ github.event.pull_request.head.sha }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
running-workflow-name: 'dependency-approval'
- name: Classify the update
id: classify
run: |
TITLE="${{ github.event.pull_request.title }}"
if echo "$TITLE" | grep -qiE '\(major\)|major'; then
echo "risk=major" >> "$GITHUB_OUTPUT"
elif echo "$TITLE" | grep -qiE 'group with|grouped'; then
echo "risk=grouped" >> "$GITHUB_OUTPUT"
else
echo "risk=auto" >> "$GITHUB_OUTPUT"
fi
- name: Approve + enable auto-merge for safe updates
if: steps.classify.outputs.risk == 'auto'
run: |
gh pr review --approve "${{ github.event.pull_request.html_url }}"
gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Only the CI-verified, non-major PRs get a bot approval. Everything else stays open for a human.
Step 2: major and grouped updates ask a human
The risky class never touches the auto-approve path — it creates a decision with the changelog context instead:
- name: Install Actionbox CLI
if: steps.classify.outputs.risk != 'auto'
run: curl -fsSL https://actionbox.cloud/install.sh | sh
- name: Request human approval for major / grouped updates
if: steps.classify.outputs.risk != 'auto'
id: approval
env:
ACTIONBOX_TOKEN: ${{ secrets.ACTIONBOX_TOKEN }}
run: |
result=$(actionbox ask "Approve dependency update: ${{ github.event.pull_request.title }}" \
--option approve="Approve and merge" \
--option reject="Reject update" \
--context-json "[{\"type\":\"key_value\",\"items\":{\"risk\":\"${{ steps.classify.outputs.risk }}\",\"pr\":\"${{ github.event.pull_request.html_url }}\",\"ci_status\":\"passing\",\"check\":\"wait-on-check + test suite\"}}]" \
--wait --timeout 24h --json)
echo "decision=$(jq -r '.decision // empty' <<< "$result")" >> "$GITHUB_OUTPUT"
- name: Approve + auto-merge on human approval
if: steps.approval.outputs.decision == 'approve'
run: |
gh pr review --approve "${{ github.event.pull_request.html_url }}"
gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Comment on reject or timeout
if: steps.approval.outputs.decision != 'approve'
run: |
gh pr comment "${{ github.event.pull_request.html_url }}" \
--body "Dependency update not approved (decision: ${{ steps.approval.outputs.decision }}). Review the changelog before merging."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}The reviewer sees the PR link, the risk class, and the CI status in the dashboard — then decides from the phone instead of losing the PR in a review queue.
Step 3: fail closed on timeout
A stale major bump must not merge silently on the 7th day. The decision expires explicitly:
- name: Fail closed on timeout
if: steps.approval.outputs.decision == 'expired'
run: |
gh pr comment "${{ github.event.pull_request.html_url }}" \
--body "Update expired without approval — re-request when ready."
exit 0
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}The audit record shows the request, the decision, and the reason for every dependency that ever merged — so "who approved the next bump?" is one query, not a git-blame hunt.
Full example: Renovate with per-package rules
{
"extends": ["config:base"],
"automerge": false,
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
"automerge": true,
"platformAutomerge": true
},
{
"matchUpdateTypes": ["major"],
"assignees": ["@release-owner"],
"reviewers": ["@release-owner"]
}
]
}Renovate merges the safe class itself; majors land in the approval queue with a named owner. Combine with the workflow above and every class of update has exactly one decision path.
Why this beats "auto-merge everything"
- Safe updates ship on their own — patches and minors with green CI, no sign-off needed
- Major bumps get judgment — a person sees the version jump and the changelog before merge
- The rule is visible — the tier table is the policy; nobody decides case-by-case
- Every merge is on record — who approved what update, when, and why
Try it
- Install the CLI on your runner
- Create a Source →
ACTIONBOX_TOKEN - Copy the workflow and set your tier rules
Create a free Source · GitHub Actions reference · Deploy approval gates in GitHub Actions