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 ]]