Skip to content
Open
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
77 changes: 65 additions & 12 deletions .github/workflows/dependabot-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
required: false
type: number
default: 7
ci-workflow:
description: Name of the CI workflow whose latest run on the head must pass. Used when an approval triggers the run.
required: false
type: string
default: CI

permissions:
actions: read
Expand All @@ -21,14 +26,25 @@ permissions:
jobs:
auto-merge:
runs-on: ubuntu-latest
# The reusable workflow keeps the caller's workflow_run event context.
# The reusable workflow keeps the caller's event context. CI finishing and
# an approval both start a run, and each run checks the current head from
# scratch, so a merge needs green CI and an approval on that same commit.
# Nothing is left armed between runs.
if: >
github.repository_owner == 'darkroomengineering' &&
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.name != 'ci-gate' &&
github.event.workflow_run.head_repository.full_name == github.repository
github.repository_owner == 'darkroomengineering' && (
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.name != 'ci-gate' &&
github.event.workflow_run.head_repository.full_name == github.repository) ||
(github.event_name == 'pull_request_review' &&
github.event.review.state == 'approved' &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository))
concurrency:
group: dependabot-merge-${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha }}
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha }}
steps:
- name: Validate update policy
env:
Expand All @@ -43,7 +59,6 @@ jobs:
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
REPO: ${{ github.repository }}
run: |
set -o pipefail
Expand Down Expand Up @@ -74,7 +89,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ steps.pr.outputs.number }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
UPDATE_POLICY: ${{ inputs.update-policy }}
run: |
set -o pipefail
Expand Down Expand Up @@ -185,32 +199,71 @@ jobs:
echo "$PACKAGE $VERSION is $age days old."
echo "old_enough=true" >> "$GITHUB_OUTPUT"

- name: Verify latest CI run and squash merge
- name: Verify latest CI run, approval and squash merge
if: >
steps.pr.outputs.number != '' &&
steps.update_type.outputs.skip == 'false' &&
steps.age.outputs.old_enough == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
EVENT: ${{ github.event_name }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }}
CI_WORKFLOW: ${{ inputs.ci-workflow }}
REPO: ${{ github.repository }}
PR: ${{ steps.pr.outputs.number }}
run: |
set -o pipefail
# A CI-triggered run must be the latest run and attempt on the head.
# An approval-triggered run looks up the named CI workflow instead.
exact=true
if [ "$EVENT" != 'workflow_run' ]; then
if [ "$CI_WORKFLOW" = 'ci-gate' ]; then
echo 'ci-gate does not prove an update works; not auto-merging.'
exit 0
fi
ids=$(gh api --paginate --slurp "/repos/$REPO/actions/workflows?per_page=100" | \
jq --arg name "$CI_WORKFLOW" '[.[].workflows[] | select(.name == $name) | .id]')
if [ "$(jq 'length' <<< "$ids")" -ne 1 ]; then
echo "Expected exactly one workflow named $CI_WORKFLOW; not auto-merging."
exit 0
fi
WORKFLOW_ID=$(jq '.[0]' <<< "$ids")
RUN_ID=0 RUN_ATTEMPT=0 exact=false
fi
if ! gh api --paginate --slurp \
"/repos/$REPO/actions/workflows/$WORKFLOW_ID/runs?event=pull_request&head_sha=$HEAD_SHA&per_page=100" | \
jq -e --arg sha "$HEAD_SHA" --argjson attempt "$RUN_ATTEMPT" \
--argjson run "$RUN_ID" --argjson workflow "$WORKFLOW_ID" \
--argjson exact "$exact" \
'[.[].workflow_runs[] | select(.workflow_id == $workflow and
.event == "pull_request" and .head_sha == $sha)] |
max_by([.created_at, .id]) |
.id == $run and .run_attempt == $attempt and
(if $exact then .id == $run and .run_attempt == $attempt else true end) and
.status == "completed" and .conclusion == "success"' > /dev/null; then
echo "CI was superseded, rerun or is not successful; not auto-merging."
exit 0
fi

# The workflow never approves. When the branch rules want a review,
# a person must have approved this exact commit, so an approval of an
# earlier head does not carry over to a version this run has not seen.
decision=$(gh pr view "$PR" --repo "$REPO" --json reviewDecision --jq '.reviewDecision')
case "$decision" in
'') ;;
APPROVED)
if ! gh api --paginate --slurp "/repos/$REPO/pulls/$PR/reviews?per_page=100" | \
jq -e --arg sha "$HEAD_SHA" \
'any(.[][]; .state == "APPROVED" and .commit_id == $sha and .user.type == "User")' > /dev/null; then
echo "No approval on $HEAD_SHA yet; waiting for one."
exit 0
fi
;;
*)
echo "Review decision is $decision; waiting for an approval."
exit 0
;;
esac
gh pr merge "$PR" --repo "$REPO" --squash --delete-branch \
--match-head-commit "$HEAD_SHA"
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,25 @@ Every change to `ci-gate.yml` changes the gate for the whole organization.
## Dependabot merge policy

Call `darkroomengineering/.github/.github/workflows/dependabot-merge.yml@<reviewed-full-commit-sha>`
as a job from a `workflow_run` caller listening for completion of its verified
CI workflow. Pass the required `update-policy` input, and optionally
`min-release-age-days`:
as a job from a caller that listens for two events: completion of its verified
CI workflow, and submitted pull request reviews. Pass the required
`update-policy` input, and optionally `min-release-age-days` and `ci-workflow`
(the CI workflow's name, default `CI`):

```yaml
on:
workflow_run:
workflows: ['CI']
types: [completed]
pull_request_review:
types: [submitted]
```

The workflow never approves a PR. Where a ruleset requires a review, it merges
only when a person has approved the exact head commit, so whichever comes last,
green CI or the approval, triggers the merge. Both runs check the head from
scratch, and nothing stays armed between them: a later Dependabot push to the
PR needs its own CI and its own approval.

| Policy | Eligible updates |
| --- | --- |
Expand All @@ -81,15 +97,15 @@ CI workflow. Pass the required `update-policy` input, and optionally

Major, 0.x, prerelease, grouped and unrecognized titles remain manual. The workflow
requires a unique open, nondraft Dependabot PR from the same repository to `main`,
the exact current head, and a successful latest run and attempt of the triggering
CI workflow. Actions-only changes require the complete paginated file list,
the exact current head, and a successful latest run and attempt of the CI
workflow on that head. Actions-only changes require the complete paginated file list,
including previous paths for renames. Merge uses `--match-head-commit` without
an administrative bypass.

Two more guards apply to every caller:

- **Real CI only.** The workflow does not merge when the triggering run is
`ci-gate`. That gate only type-checks, which does not prove that an update
- **Real CI only.** The workflow does not merge when the triggering run, or
`ci-workflow`, is `ci-gate`. That gate only type-checks, which does not prove that an update
works. The caller's workflow must build and test the project.
- **Release age.** The updated version must be public for at least
`min-release-age-days` (default 7). Malicious releases are usually found and
Expand All @@ -106,7 +122,7 @@ cooldown:
```

Without the cooldown, a PR for a young release stays open and is not merged.
The workflow runs again only when its CI runs again.
The workflow runs again only when its CI runs again or someone approves.

The caller grants `actions: read`, `contents: write` and `pull-requests: write`.
The workflow uses the caller's automatic `GITHUB_TOKEN`; do not pass a token or
Expand Down
Loading