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
28 changes: 28 additions & 0 deletions .github/actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,39 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: promptLM/.github/.github/actions/auto-merge-deps@<sha>
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@<sha>
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 `<job-id> / <matrix-or-job-name>`,
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
Expand Down
60 changes: 59 additions & 1 deletion .github/actions/auto-merge-deps/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -65,14 +74,27 @@ 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:
description: |
Outcome of the filter chain. One of:
proceed:merge-queued, proceed:dry-run,
skip:draft, skip:author, skip:label:<name>,
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:
Expand All @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}'
Expand Down
Loading