From 5685a071badc11cd84c503dbf29f44cf7b558a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Kr=C3=BCger?= Date: Fri, 5 Jun 2026 00:13:25 +0200 Subject: [PATCH] feat(auto-merge-deps): gate auto-merge on required PR check-runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `required-checks` input (newline- or comma-separated) to the composite action. When set, the action queries the Checks API for the PR head SHA and requires each listed check-run to be `completed` with `conclusion=success` before calling `gh pr merge --auto`. Missing, pending, or failing checks exit cleanly with `skip:check:{missing,pending,failed}` and a log line naming the check. Empty input preserves prior behaviour — enforcement falls back to the caller repo's branch protection. This is defense-in-depth so the action's "safe auto-merge" intent holds even when a repo forgets to mark its build as required. Closes #20. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/README.md | 28 ++++++++++ .github/actions/auto-merge-deps/action.yml | 60 +++++++++++++++++++- .github/workflows/test.yml | 64 ++++++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/.github/actions/README.md b/.github/actions/README.md index 2462430..261b101 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -105,11 +105,39 @@ jobs: runs-on: ubuntu-latest steps: - uses: promptLM/.github/.github/actions/auto-merge-deps@ + with: + required-checks: | + oss-checks / run # with: # method: squash # merge | squash | rebase # include-major: 'true' # default 'false' ``` +### Gating on a build check (`required-checks`) + +Branch protection is the primary gate, but it's enforced *out of band* — +a repo that forgets to mark its build job as required will silently +auto-merge broken dependency PRs. `required-checks` is a defense-in-depth +gate inside the action itself: a newline- or comma-separated list of +check-run names that must already have completed with `conclusion=success` +on the PR head SHA before `gh pr merge --auto` is called. If any listed +check is missing, still pending, or failing, the action exits cleanly +with `decision=skip:check:{missing,pending,failed}` and a log line +naming the offending check. Leaving the input empty preserves prior +behaviour (no extra gate). + +```yaml + - uses: promptLM/.github/.github/actions/auto-merge-deps@ + with: + required-checks: | + oss-checks / run + build +``` + +The name(s) must match the check-run `name` GitHub records — for a +reusable workflow that's typically ` / `, +e.g. `oss-checks / run`. + ### Why `pull_request_target`, not `pull_request`? `pull_request` runs with a read-only token on PRs from forks, which diff --git a/.github/actions/auto-merge-deps/action.yml b/.github/actions/auto-merge-deps/action.yml index ad37d86..25378ab 100644 --- a/.github/actions/auto-merge-deps/action.yml +++ b/.github/actions/auto-merge-deps/action.yml @@ -41,6 +41,15 @@ inputs: description: "Comma-separated labels that, if any is present on the PR, abort auto-merge." required: false default: 'do-not-merge,blocked,breaking-change' + required-checks: + description: | + Optional list of check-run names that must have already completed with + `conclusion=success` on the PR head SHA before auto-merge is enabled. + Newline- or comma-separated. Whitespace around each name is trimmed. + Empty (the default) disables the gate and preserves prior behaviour — + enforcement relies solely on the caller repo's branch protection. + required: false + default: '' dry-run: description: "If 'true', emit the decision via the `decision` output and skip the actual `gh pr merge` call. Used by the test workflow." required: false @@ -65,6 +74,17 @@ inputs: description: "Override for github.event.pull_request.draft (test seam)." required: false default: '' + pr-head-sha: + description: "Override for github.event.pull_request.head.sha (test seam)." + required: false + default: '' + checks-json: + description: | + Test seam: when set, this JSON array of `{name, status, conclusion}` + objects is consulted instead of the live Checks API. Used by the test + workflow to exercise every branch of the required-checks gate. + required: false + default: '' outputs: decision: @@ -72,7 +92,9 @@ outputs: Outcome of the filter chain. One of: proceed:merge-queued, proceed:dry-run, skip:draft, skip:author, skip:label:, - skip:major, skip:merge-failed + skip:major, + skip:check:missing, skip:check:pending, skip:check:failed, + skip:merge-failed value: ${{ steps.decide.outputs.decision }} runs: @@ -87,10 +109,13 @@ runs: PR_AUTHOR: ${{ inputs.pr-author != '' && inputs.pr-author || github.event.pull_request.user.login }} PR_LABELS: ${{ inputs.pr-labels-json != '' && inputs.pr-labels-json || toJson(github.event.pull_request.labels.*.name) }} PR_DRAFT: ${{ inputs.pr-draft != '' && inputs.pr-draft || github.event.pull_request.draft }} + PR_HEAD_SHA: ${{ inputs.pr-head-sha != '' && inputs.pr-head-sha || github.event.pull_request.head.sha }} METHOD: ${{ inputs.method }} INCLUDE_MAJOR: ${{ inputs.include-major }} ALLOWED_ACTORS: ${{ inputs.allowed-actors }} BLOCKING_LABELS: ${{ inputs.blocking-labels }} + REQUIRED_CHECKS: ${{ inputs.required-checks }} + CHECKS_JSON: ${{ inputs.checks-json }} DRY_RUN: ${{ inputs.dry-run }} GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -132,6 +157,39 @@ runs: emit "skip:major"; exit 0 fi fi + if [ -n "$REQUIRED_CHECKS" ]; then + normalized=$(printf '%s\n' "$REQUIRED_CHECKS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -v '^$' || true) + if [ -n "$CHECKS_JSON" ]; then + checks="$CHECKS_JSON" + else + if [ -z "$PR_HEAD_SHA" ]; then + echo "required-checks set but no PR head SHA available; cannot verify" + emit "skip:check:missing"; exit 0 + fi + if ! checks=$(gh api "repos/$REPO/commits/$PR_HEAD_SHA/check-runs?per_page=100" --paginate --jq '[.check_runs[] | {name, status, conclusion}]' 2>&1 | jq -s 'add // []'); then + echo "failed to fetch check-runs for $PR_HEAD_SHA" + emit "skip:check:missing"; exit 0 + fi + fi + while IFS= read -r needed; do + [ -z "$needed" ] && continue + match=$(echo "$checks" | jq -c --arg n "$needed" '[.[] | select(.name == $n)] | last // empty') + if [ -z "$match" ]; then + echo "required check '$needed' is missing on $PR_HEAD_SHA" + emit "skip:check:missing"; exit 0 + fi + status=$(echo "$match" | jq -r '.status') + conclusion=$(echo "$match" | jq -r '.conclusion') + if [ "$status" != "completed" ]; then + echo "required check '$needed' is not completed (status=$status, conclusion=$conclusion)" + emit "skip:check:pending"; exit 0 + fi + if [ "$conclusion" != "success" ]; then + echo "required check '$needed' did not succeed (conclusion=$conclusion)" + emit "skip:check:failed"; exit 0 + fi + done <<< "$normalized" + fi if [ "$DRY_RUN" = "true" ]; then emit "proceed:dry-run"; exit 0 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 77d4ce6..26496a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -153,6 +153,67 @@ jobs: include-major: 'false' draft: 'true' expected: 'skip:draft' + - name: required-check-success-proceeds + author: 'dependabot[bot]' + title: 'Bump foo from 1.2.3 to 1.3.0' + labels: '[]' + include-major: 'false' + required-checks: 'oss-checks / run' + checks-json: '[{"name":"oss-checks / run","status":"completed","conclusion":"success"}]' + expected: 'proceed:dry-run' + - name: required-check-missing-skips + author: 'dependabot[bot]' + title: 'Bump foo from 1.2.3 to 1.3.0' + labels: '[]' + include-major: 'false' + required-checks: 'oss-checks / run' + checks-json: '[{"name":"other","status":"completed","conclusion":"success"}]' + expected: 'skip:check:missing' + - name: required-check-pending-skips + author: 'dependabot[bot]' + title: 'Bump foo from 1.2.3 to 1.3.0' + labels: '[]' + include-major: 'false' + required-checks: 'oss-checks / run' + checks-json: '[{"name":"oss-checks / run","status":"in_progress","conclusion":null}]' + expected: 'skip:check:pending' + - name: required-check-failed-skips + author: 'dependabot[bot]' + title: 'Bump foo from 1.2.3 to 1.3.0' + labels: '[]' + include-major: 'false' + required-checks: 'oss-checks / run' + checks-json: '[{"name":"oss-checks / run","status":"completed","conclusion":"failure"}]' + expected: 'skip:check:failed' + - name: required-checks-multiline-all-pass + author: 'dependabot[bot]' + title: 'Bump foo from 1.2.3 to 1.3.0' + labels: '[]' + include-major: 'false' + required-checks: | + oss-checks / run + build + checks-json: '[{"name":"oss-checks / run","status":"completed","conclusion":"success"},{"name":"build","status":"completed","conclusion":"success"}]' + expected: 'proceed:dry-run' + - name: required-checks-multiline-one-failing-skips + author: 'dependabot[bot]' + title: 'Bump foo from 1.2.3 to 1.3.0' + labels: '[]' + include-major: 'false' + required-checks: | + oss-checks / run + build + checks-json: '[{"name":"oss-checks / run","status":"completed","conclusion":"success"},{"name":"build","status":"completed","conclusion":"failure"}]' + expected: 'skip:check:failed' + - name: required-check-uses-latest-rerun + author: 'dependabot[bot]' + title: 'Bump foo from 1.2.3 to 1.3.0' + labels: '[]' + include-major: 'false' + required-checks: 'build' + # Older failing run + newer successful re-run for the same name: take last. + checks-json: '[{"name":"build","status":"completed","conclusion":"failure"},{"name":"build","status":"completed","conclusion":"success"}]' + expected: 'proceed:dry-run' steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - id: decide @@ -161,10 +222,13 @@ jobs: dry-run: 'true' include-major: ${{ matrix.include-major }} pr-number: '1' + pr-head-sha: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' pr-author: ${{ matrix.author }} pr-title: ${{ matrix.title }} pr-labels-json: ${{ matrix.labels }} pr-draft: ${{ matrix.draft || 'false' }} + required-checks: ${{ matrix.required-checks || '' }} + checks-json: ${{ matrix.checks-json || '' }} - name: Assert decision matches expectation run: | actual='${{ steps.decide.outputs.decision }}'