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
114 changes: 112 additions & 2 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ name: Dependabot Auto-Merge
# trusted namespace (dependabot/, actions/, plus each caller's own
# org — see the `trusted_namespaces` input below). One
# untrusted dep in a grouped update blocks the whole group.
# - Major, opt-in (allow_major): one dep, merged only after ALL
# checks finish green. See README.
# - Uses pull_request_target so the BASE-branch workflow runs, not
# the PR branch's — a PR modifying this file cannot bypass itself.
# - Never executes PR code; the only actions taken are `gh pr review`
Expand Down Expand Up @@ -50,8 +52,8 @@ name: Dependabot Auto-Merge
#
# `gh pr review --approve` satisfies branch-protection rules that
# require review; it's a no-op on repos where no approving review is
# required. `gh pr merge --auto` means the merge only happens after
# all status checks pass; failing CI leaves the PR open indefinitely.
# required. `gh pr merge --auto` waits only on REQUIRED checks; a red
# non-required build does not stop it.
# Both `gh` calls degrade to a visible ::warning:: rather than a hard
# failure — see #87 (a hard failure on either call left an
# unactionable red check with no visible cause).
Expand All @@ -67,6 +69,7 @@ name: Dependabot Auto-Merge
# uses: smartwatermelon/github-workflows/.github/workflows/dependabot-auto-merge.yml@dependabot-auto-merge-v2
# with:
# trusted_namespaces: 'smartwatermelon' # dependabot/actions always trusted; add your org(s)
# allow_major: true # optional opt-in; default false
#
# Do NOT add a `secrets:` block to the caller (see above).
#
Expand Down Expand Up @@ -102,6 +105,16 @@ on:
type: string
required: false
default: ''
allow_major:
description: >-
Opt in to auto-merging a single-dependency MAJOR bump outside
trusted_namespaces. It merges only after every check on the
PR head has finished green (not just required checks). See
the README. Grouped PRs are never
affected. Default false keeps the pre-input behavior.
type: boolean
required: false
default: false

permissions:
contents: write
Expand All @@ -114,6 +127,8 @@ jobs:
# ref, which a human pushing to an existing Dependabot PR can spoof).
if: github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-latest
# Bounds the opt-in wait (settle + 40 min deadline).
timeout-minutes: 50
permissions:
contents: write
pull-requests: write
Expand Down Expand Up @@ -158,6 +173,7 @@ jobs:
NEW_VERSION: ${{ steps.metadata.outputs.new-version }}
DEP_NAMES: ${{ steps.metadata.outputs.dependency-names }}
TRUSTED_NAMESPACES: ${{ inputs.trusted_namespaces }}
ALLOW_MAJOR: ${{ inputs.allow_major }}
run: |
set -euo pipefail
extract_major() {
Expand Down Expand Up @@ -223,6 +239,12 @@ jobs:
if [ -z "$remainder" ]; then
echo "decision=merge" >> "$GITHUB_OUTPUT"
echo "::notice::$PREV_VERSION -> $NEW_VERSION, deps in trusted namespace; auto-merging"
elif [ "${ALLOW_MAJOR:-false}" = "true" ] && [ "$dep_count" -eq 1 ] \
&& [ -n "${prev_major:-}" ] && [ -n "${new_major:-}" ] \
&& [ "$prev_major" != "$new_major" ]; then
# Opt-in major path (dev-env#120); the gate step waits for ALL checks.
echo "decision=major-gate" >> "$GITHUB_OUTPUT"
echo "::notice::Major v$prev_major -> v$new_major ($remainder); allow_major is on, waiting for all checks"
else
echo "decision=skip" >> "$GITHUB_OUTPUT"
echo "::notice::Non-allowlisted deps present: $remainder"
Expand All @@ -238,3 +260,91 @@ jobs:
|| echo "::warning::Approval failed (check can_approve_pull_request_reviews setting); continuing."
gh pr merge --auto --squash --delete-branch "$PR_URL" \
|| echo "::warning::Auto-merge enable failed — check branch protection / auto-merge repo setting."

# Every exit is gate=pass or a ::notice:: skip, never red (#87).
- name: Wait for all checks (major, opt-in)
id: gate
if: steps.policy.outputs.decision == 'major-gate'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_ID: ${{ github.run_id }}
SELF_WORKFLOW: ${{ github.workflow }}
SETTLE_SECONDS: '120'
POLL_SECONDS: '30'
WAIT_SECONDS: '2400'
run: |
set -euo pipefail
skip() {
echo "gate=skip" >> "$GITHUB_OUTPUT"
echo "::notice::Major bump left for manual review: $1"
exit 0
}
deadline=$(( $(date +%s) + WAIT_SECONDS ))
# Netlify checks register late: settle, then need two identical finished polls.
sleep "$SETTLE_SECONDS"
last=""
while :; do
if [ "$(date +%s)" -ge "$deadline" ]; then
skip "checks still running after $(( WAIT_SECONDS / 60 )) min"
fi
head=$(gh pr view "$PR_URL" --json headRefOid --jq .headRefOid 2>/dev/null || true)
if [ "$head" != "$HEAD_SHA" ]; then
skip "PR head is now '${head:-unreadable}', not $HEAD_SHA; the run for the new head decides"
fi
# Exits nonzero while pending/failing; judge the JSON.
json=$(gh pr checks "$PR_URL" --json name,workflow,bucket,link 2>/dev/null || true)
# bad_names is last: read collapses empty tab fields. Unknown buckets count as pending.
if ! summary=$(printf '%s' "$json" | jq -er \
--arg run "/actions/runs/${RUN_ID}/" --arg wf "$SELF_WORKFLOW" '
if type != "array" then error("not an array") else . end
| (map(select((.link // "") | contains($run))) | length) as $self
| map(select(((.link // "") | contains($run)) | not)
| select((.workflow // "") != $wf)) as $o
| ($o | map(select(.bucket == "fail" or .bucket == "cancel"))) as $bad
| [ $self,
($o | length),
($o | map(select(.bucket as $b
| ["pass", "skipping", "fail", "cancel"] | index($b) | not))
| length),
($bad | length),
($o | map(select(.bucket == "pass")) | length),
($o | map("\(.name)=\(.bucket)") | sort | join("|")),
($bad | map(.name) | join(", ")) ]
| @tsv'); then
skip "could not read the PR's checks"
fi
IFS=$'\t' read -r self others pending bad passed sig bad_names <<< "$summary"
if [ "$self" -eq 0 ]; then
# No checks: read; seeing itself proves the list is not a permission blank.
skip "this run cannot see its own check run (no checks: read on a private repo?)"
fi
if [ "$bad" -gt 0 ]; then
skip "failing or cancelled checks: $bad_names"
fi
if [ "$pending" -eq 0 ] && [ "$passed" -ge 1 ]; then
if [ "$sig" = "$last" ]; then
echo "gate=pass" >> "$GITHUB_OUTPUT"
echo "::notice::All $others other checks finished with no failure on $HEAD_SHA"
exit 0
fi
last="$sig"
else
last=""
fi
sleep "$POLL_SECONDS"
done

- name: Approve and merge (major, opt-in)
if: steps.gate.outputs.gate == 'pass'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
gh pr review --approve "$PR_URL" \
|| echo "::warning::Approval failed (check can_approve_pull_request_reviews setting); continuing."
# Not --auto: it survives a rebase and then waits on required checks only.
gh pr merge --squash --delete-branch --match-head-commit "$HEAD_SHA" "$PR_URL" \
|| echo "::warning::Merge failed — head moved, approval missing, or branch protection blocked it."
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Testing and linting

- `bash tests/run-tests.sh` runs the standards suites (`test-run-standards.sh`, `test-check-node-floor.sh`).
Requires `shellcheck`, `yamllint`, `actionlint`, `zizmor`, and `markdownlint-cli2` (all via Homebrew), plus `jq`.
Requires `shellcheck`, `yamllint`, `actionlint`, `zizmor`, and `markdownlint-cli2` (all via Homebrew), plus `jq`,
and `yq` for `test-dependabot-auto-merge.sh`, which extracts that workflow's inline `run:` blocks.
- `bash standards/run-standards.sh --repo <dir>` runs the exact same standards
check CI runs, against a local checkout — this is the command
`standards-check.yml` calls in CI, so a clean local run predicts a clean
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,38 @@ cannot safely start its own bare `v1` while one already exists.
## `dependabot-auto-merge`

Reusable workflow: approves and auto-merges Dependabot PRs for patch and
minor version updates once CI passes. Major-version bumps are left open
for manual review.
minor version updates once the **required** checks pass. Major-version
bumps are left open for manual review, unless the dependency is in a
trusted namespace or the caller opts in with `allow_major: true`.

"Required" means branch protection's list, not every check. `gh pr merge
--auto` waits only on required checks, and most fleet repos require only
`standards-check`. On the patch/minor path, a red `build` that is not
required does not stop the merge.

### Opt-in: `allow_major`

`allow_major: true` (default `false`) lets a **single-dependency** major
bump merge without a human, under a stricter gate than the patch/minor
path (smartwatermelon/dev-env#120):

- The job waits until **every** check on the PR head SHA has finished,
required or not, except this workflow's own. Any failed or cancelled
check leaves the PR for manual review. SKIPPED and NEUTRAL do not block.
- It then merges directly with `--match-head-commit`, not `--auto`, so a
later Dependabot rebase cannot inherit an armed auto-merge.
- It gives up with a `::notice::` (never a red check) after about 40
minutes, when the head moves, or when it cannot see its own check run.
The job's token has no `checks` permission; on a private repo the check
list may come back blank, and the self-visibility test turns that into
"manual review" instead of a false "all green".
- Grouped PRs never take this path.
- Pair it with a Dependabot `cooldown` so a major is some days old before
its PR opens. `semver-major-days` works only for ecosystems that support
SemVer (npm, pip, uv, and others); `github-actions` honors only
`default-days`. **Security updates ignore `cooldown`**, so with
`allow_major: true` a security-driven major can merge on day 0 once all
checks are green.

### Setup

Expand Down Expand Up @@ -170,6 +200,7 @@ jobs:
uses: smartwatermelon/github-workflows/.github/workflows/dependabot-auto-merge.yml@dependabot-auto-merge-v2
with:
trusted_namespaces: 'smartwatermelon' # adjust to your own org; dependabot/actions are always trusted
# allow_major: true # optional; see "Opt-in: allow_major" above
```

Also add `can_approve_pull_request_reviews: true` to the repo's Actions
Expand Down
24 changes: 24 additions & 0 deletions tests/stub-dependabot/gh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Stub gh: pr view prints head; pr checks prints checks.<n>.json
# (last repeats) and exits 8 while pending, like gh.
set -euo pipefail
: "${STUB_DIR:?}"
printf '%s\n' "$*" >>"${STUB_DIR}/calls.log"
case "$1 $2" in
"pr view")
cat "${STUB_DIR}/head"
;;
"pr checks")
n=$(($(cat "${STUB_DIR}/checks.count" 2>/dev/null || echo 0) + 1))
echo "${n}" >"${STUB_DIR}/checks.count"
f="${STUB_DIR}/checks.${n}.json"
while [[ ! -f "${f}" && "${n}" -gt 1 ]]; do n=$((n - 1)); f="${STUB_DIR}/checks.${n}.json"; done
[[ -f "${f}" ]] || { echo "stub gh: no fixture ${f}" >&2; exit 98; }
cat "${f}"
if jq -e 'any(.[]; .bucket == "pending")' "${f}" >/dev/null 2>&1; then exit 8; fi
;;
*)
echo "stub gh: unhandled $*" >&2
exit 99
;;
esac
Loading
Loading