diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml
index 6b0042e..a7a204d 100644
--- a/.github/workflows/dependabot-auto-merge.yml
+++ b/.github/workflows/dependabot-auto-merge.yml
@@ -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`
@@ -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).
@@ -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).
#
@@ -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
@@ -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
@@ -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() {
@@ -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"
@@ -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."
diff --git a/CLAUDE.md b/CLAUDE.md
index a5696c1..b2d4698 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
` 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
diff --git a/README.md b/README.md
index 81b56cf..1196f5a 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
diff --git a/tests/stub-dependabot/gh b/tests/stub-dependabot/gh
new file mode 100755
index 0000000..c9f8ecc
--- /dev/null
+++ b/tests/stub-dependabot/gh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Stub gh: pr view prints head; pr checks prints checks..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
diff --git a/tests/test-dependabot-auto-merge.sh b/tests/test-dependabot-auto-merge.sh
new file mode 100755
index 0000000..e634f11
--- /dev/null
+++ b/tests/test-dependabot-auto-merge.sh
@@ -0,0 +1,155 @@
+#!/usr/bin/env bash
+# Tests dependabot-auto-merge.yml's policy and gate run: blocks,
+# extracted with yq, against tests/stub-dependabot/gh.
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+wf="${here}/../.github/workflows/dependabot-auto-merge.yml"
+unset -f gh 2>/dev/null || true
+unset BASH_ENV
+pass=0; fail=0
+tmp_dirs=()
+trap 'rm -rf "${tmp_dirs[@]}"' EXIT
+_ok() { echo " ok $1"; pass=$((pass + 1)); }
+_bad() { echo " FAIL $1"; fail=$((fail + 1)); }
+
+_step() { # _step NAME: the run: block of the named step
+ yq ".jobs.auto-merge.steps[] | select(.name == \"$1\") | .run" "${wf}"
+}
+policy_sh="$(_step 'Decide if PR is auto-mergeable')"
+gate_sh="$(_step 'Wait for all checks (major, opt-in)')"
+[[ -n "${policy_sh}" && -n "${gate_sh}" ]] || { echo "could not extract run blocks"; exit 1; }
+
+_fixture() {
+ STUB_DIR="$(mktemp -d)"; export STUB_DIR
+ tmp_dirs+=("${STUB_DIR}")
+ : >"${STUB_DIR}/calls.log"
+ : >"${STUB_DIR}/out"
+}
+
+# --- policy step -------------------------------------------------------
+# policy PREV NEW DEPS ALLOW [TRUSTED] -> sets decision
+policy() {
+ _fixture
+ rc=0
+ PREV_VERSION="$1" NEW_VERSION="$2" DEP_NAMES="$3" ALLOW_MAJOR="$4" \
+ TRUSTED_NAMESPACES="${5-}" GITHUB_OUTPUT="${STUB_DIR}/out" \
+ bash -c "${policy_sh}" >/dev/null 2>&1 || rc=$?
+ decision="$(sed -n 's/^decision=//p' "${STUB_DIR}/out")"
+}
+_expect_policy() { # label expected
+ if [[ "${rc}" -eq 0 && "${decision}" == "$2" ]]; then _ok "$1"; else _bad "$1: rc=${rc} decision='${decision}'"; fi
+}
+
+policy 5.9.3 6.0.3 typescript false
+_expect_policy "single untrusted major, allow_major off -> skip (unchanged)" skip
+policy 5.9.3 6.0.3 typescript true
+_expect_policy "single untrusted major, allow_major on -> major-gate" major-gate
+policy 5.9.3 6.0.3 "typescript, eslint" true
+_expect_policy "grouped PR with allow_major on -> skip (groups unchanged)" skip
+policy 5.9.3 5.10.0 typescript true
+_expect_policy "single minor -> merge (unchanged)" merge
+policy abc def typescript true
+_expect_policy "unparseable versions with allow_major on -> skip" skip
+policy 1.0.0 2.0.0 smartwatermelon/thing true smartwatermelon
+_expect_policy "trusted single major keeps the --auto path -> merge" merge
+policy 1.0.0 2.0.0 "" true
+_expect_policy "empty dependency-names with allow_major on -> skip" skip
+
+# --- gate step ---------------------------------------------------------
+sha="0123456789abcdef0123456789abcdef01234567"
+run_id="4242"
+self_link="https://github.com/acme/site/actions/runs/${run_id}/job/1"
+# _checks N SPEC...: nth checks call. SPEC: "self" or NAME|BUCKET[|WORKFLOW].
+_checks() {
+ local n="$1"; shift
+ local spec name bucket wfname rows="[]"
+ for spec in "$@"; do
+ if [[ "${spec}" == "self" ]]; then
+ rows="$(jq -c --arg l "${self_link}" \
+ '. + [{name: "dependabot-auto-merge / auto-merge", workflow: "Dependabot Auto-Merge", bucket: "pending", link: $l}]' \
+ <<<"${rows}")"
+ continue
+ fi
+ IFS='|' read -r name bucket wfname <<<"${spec}"
+ rows="$(jq -c --arg n "${name}" --arg b "${bucket}" --arg w "${wfname:-CI}" \
+ '. + [{name: $n, workflow: $w, bucket: $b, link: "https://github.com/acme/site/actions/runs/99/job/2"}]' \
+ <<<"${rows}")"
+ done
+ printf '%s\n' "${rows}" >"${STUB_DIR}/checks.${n}.json"
+}
+_raw_checks() { printf '%s\n' "$2" >"${STUB_DIR}/checks.$1.json"; }
+gate() {
+ rc=0
+ out="$(PATH="${here}/stub-dependabot:${PATH}" GITHUB_OUTPUT="${STUB_DIR}/out" \
+ PR_URL=https://github.com/acme/site/pull/7 HEAD_SHA="${sha}" RUN_ID="${run_id}" \
+ SELF_WORKFLOW="Dependabot Auto-Merge" SETTLE_SECONDS=0 POLL_SECONDS=0.1 \
+ WAIT_SECONDS="${WAIT:-3}" bash -c "${gate_sh}" 2>&1)" || rc=$?
+ result="$(sed -n 's/^gate=//p' "${STUB_DIR}/out")"
+}
+_expect_gate() { # label expected [notice-substring]
+ if [[ "${rc}" -eq 0 && "${result}" == "$2" ]] && grep -qF -- "${3-}" <<<"${out}"; then
+ _ok "$1"
+ else
+ _bad "$1: rc=${rc} gate='${result}' out=${out}"
+ fi
+}
+
+# 1. pending, then all green: passes only after two identical finished polls
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_checks 1 self "build|pending"
+_checks 2 self "build|pass" "Pages changed|skipping"
+gate
+_expect_gate "pending then green (NEUTRAL/skipped allowed) -> pass" pass "All 2 other checks"
+polls="$(<"${STUB_DIR}/checks.count")"
+if [[ "${polls}" -ge 3 ]]; then _ok "needs two identical finished polls"; else _bad "passed after ${polls} polls"; fi
+
+# 2. a failing non-required check blocks, and names it
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_checks 1 self "build|fail" "standards|pass"
+gate
+_expect_gate "failing build -> skip" skip "failing or cancelled checks: build"
+
+# 3. cancelled counts as bad
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_checks 1 self "build|cancel"
+gate
+_expect_gate "cancelled check -> skip" skip "build"
+
+# 4. cannot see its own check run -> fail closed even though the rest is green
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_checks 1 "build|pass"
+gate
+_expect_gate "own check run not visible -> skip" skip "cannot see its own check run"
+
+# 5. still pending at the deadline
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_checks 1 self "build|pending"
+WAIT=1 gate
+_expect_gate "pending past the deadline -> skip" skip "checks still running"
+
+# 6. only this workflow's own checks present: never passes on nothing
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_checks 1 self "dependabot-auto-merge / other|pass|Dependabot Auto-Merge"
+WAIT=1 gate
+_expect_gate "no other checks -> skip at deadline, not pass" skip "checks still running"
+
+# 7. head moved -> the new run decides
+_fixture; echo "fedcba9876543210fedcba9876543210fedcba98" >"${STUB_DIR}/head"
+_checks 1 self "build|pass"
+gate
+_expect_gate "head moved -> skip" skip "the run for the new head decides"
+
+# 8. unreadable checks output -> skip, not pass
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_raw_checks 1 "not json"
+gate
+_expect_gate "unreadable checks -> skip" skip "could not read"
+
+# 9. an unknown bucket counts as pending, not as pass
+_fixture; echo "${sha}" >"${STUB_DIR}/head"
+_checks 1 self "build|pass" "odd|weird"
+WAIT=1 gate
+_expect_gate "unknown bucket never passes" skip "checks still running"
+
+echo "${pass} passed, ${fail} failed"
+[[ "${fail}" -eq 0 ]]