Skip to content
Closed
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
147 changes: 147 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
name: Dependabot auto-merge

# Reusable policy for Dependabot pull requests in component repositories.
# A caller runs it on `pull_request` (opened, synchronize, reopened) and grants
# `contents: write` and `pull-requests: write`. It never checks out or runs
# pull-request code. See README.md#dependabot-auto-merge.
#
# Auto-merge is enabled only when every updated dependency is a semver patch or
# minor update, Dependabot authored every commit, and the run was triggered by
# Dependabot itself. Anything else (majors, unknown update types, a person's
# push, the policy's own inputs) disables any earlier auto-merge request and
# leaves the pull request for a person. Required CI and the repository's
# rulesets still decide when the merge happens.

on:
workflow_call:

permissions: {}

jobs:
classify:
name: Classify Dependabot update
if: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
eligible: ${{ steps.classify.outputs.eligible }}
steps:
- name: Check the triggering actor
id: actor
env:
ACTOR: ${{ github.actor }}
run: |
if [[ ${ACTOR} == 'dependabot[bot]' ]]; then
echo "dependabot=true" >>"${GITHUB_OUTPUT}"
else
echo "dependabot=false" >>"${GITHUB_OUTPUT}"
fi

- name: Fetch Dependabot metadata
id: metadata
if: steps.actor.outputs.dependabot == 'true'
# Missing or unverifiable metadata fails this step; the next step then
# treats the pull request as needing a person.
continue-on-error: true
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0

- name: Decide eligibility
id: classify
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
FROM_DEPENDABOT: ${{ steps.actor.outputs.dependabot }}
UPDATES: ${{ steps.metadata.outputs.updated-dependencies-json }}
PULL_REQUEST: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail

decide() {
echo "eligible=$1" >>"${GITHUB_OUTPUT}"
{
echo "### Dependabot auto-merge"
echo
echo "$2"
} >>"${GITHUB_STEP_SUMMARY}"
echo "$2"
exit 0
}

if [[ ${FROM_DEPENDABOT} != true ]]; then
decide false "Not eligible: this run was triggered by ${ACTOR}, not Dependabot."
fi

# fetch-metadata verifies only the first commit; require all of them.
if ! authors=$(gh api --paginate \
"repos/${REPOSITORY}/pulls/${PULL_REQUEST}/commits?per_page=100" \
--jq '.[] | (.author.login // "unknown")' | sort -u); then
decide false "Not eligible: the pull request's commits could not be read."
fi
if [[ ${authors} != 'dependabot[bot]' ]]; then
decide false "Not eligible: commits are not all authored by Dependabot."
fi

# Every update must be a known semver patch or minor update. The
# action's own update-type output ignores updates whose type is
# unknown, so read the per-dependency list instead. Updates to this
# policy's own inputs always need a person.
if ! jq -e '
type == "array" and length > 0 and
all(.[];
(.updateType | IN("version-update:semver-patch",
"version-update:semver-minor")) and
((.dependencyName // "" | ascii_downcase) as $name |
($name == "dependabot/fetch-metadata" or
($name | startswith("atrinik/github-settings"))) | not))
' <<<"${UPDATES:-[]}" >/dev/null; then
summary=$(jq -r '
if type == "array" and length > 0 then
map("- `\(.dependencyName)`: \(.updateType // "" | if . == "" then "unknown" else . end)") | join("\n")
else "- no dependency metadata" end
' <<<"${UPDATES:-[]}" 2>/dev/null || echo "- unreadable dependency metadata")
decide false "$(printf 'Not eligible: needs a person.\n\n%s' "${summary}")"
fi

decide true "Eligible: every update is a semver patch or minor update by Dependabot."

apply:
name: Apply auto-merge decision
needs: classify
# Run after a failed or timed-out classification too, so a stale request
# is still disabled. A cancelled run was superseded by a newer one.
if: ${{ !cancelled() && needs.classify.result != 'skipped' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: write
pull-requests: write
steps:
- name: Enable or disable auto-merge
env:
GH_TOKEN: ${{ github.token }}
ELIGIBLE: ${{ needs.classify.outputs.eligible }}
CLASSIFIED: ${{ needs.classify.result }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PULL_REQUEST: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
if [[ ${ELIGIBLE} == true && ${CLASSIFIED} == success ]]; then
# Bind the request to the classified head commit.
gh pr merge "${PULL_REQUEST}" --repo "${REPOSITORY}" \
--auto --squash --match-head-commit "${HEAD_SHA}"
exit 0
fi

state=$(gh pr view "${PULL_REQUEST}" --repo "${REPOSITORY}" \
--json autoMergeRequest --jq '.autoMergeRequest != null')
if [[ ${state} == true ]]; then
gh pr merge "${PULL_REQUEST}" --repo "${REPOSITORY}" --disable-auto
echo "Disabled auto-merge: this pull request needs a person."
else
echo "Auto-merge is not enabled; nothing to do."
fi
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@
required CI, restore the strict policy before retiring a queue, and retire
only an unmodified repository-owned rule that passes a fresh identity check
and final re-read.
- `.github/workflows/dependabot-auto-merge.yml` is the reusable Dependabot
auto-merge policy (owner-approved 2026-10-22): only all-patch/minor updates
authored and triggered by Dependabot, read-only classification, a write job
that runs only `gh`, and no approvals or bypass. It needs
`allow_auto_merge` in `config/repository-defaults.json` and the
`dependabot/fetch-metadata@*` allowance. Never let it approve pull requests,
check out pull-request code, or auto-merge updates of its own inputs.
- Keep required workflow job names synchronized with rulesets. Workflow or
permissions changes also require actionlint, least-privilege review, and
immutable action references according to policy.
Expand Down
98 changes: 97 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ read-only and are skipped on later runs.
publisher policy scopes.
- Active repositories allow squash merges only. The squash commit uses the
pull-request title and description, and merged head branches are deleted.
- Active repositories allow auto-merge. Dependabot patch and minor updates
enable it through the shared reusable workflow once required CI passes; see
[Dependabot auto-merge](#dependabot-auto-merge).
- Every repository uses an enforced security baseline: dependency graph,
Dependabot alerts and security updates, secret scanning, push protection,
validity checks, and private vulnerability reporting remain enabled in both
Expand Down Expand Up @@ -108,7 +111,8 @@ read-only and are skipped on later runs.
remain outside GitHub and this repository.
- GitHub Actions defaults to read-only, cannot approve pull requests, and may
use only Atrinik, GitHub, Codecov coverage, explicitly allowed Docker
actions, and `rust-lang/crates-io-auth-action@*`. Workflow references remain
actions, `rust-lang/crates-io-auth-action@*`, and
`dependabot/fetch-metadata@*` for Dependabot auto-merge. Workflow references remain
full commit SHAs; the action allowlist permits routine pin updates. The
publisher also restores per-repository workflow permission overrides to
read-only with PR approvals disabled, applying only real drift.
Expand Down Expand Up @@ -1005,6 +1009,98 @@ concurrently edited rule with that name is retained with a `SKIP`, and `bin/audi
reviews it. The audit also requires each listed repository's exact queue rule,
repository ownership, and non-strict required CI.

## Dependabot auto-merge

The owner approved auto-merging Dependabot patch and minor updates once
required CI passes (2026-10-22). Majors, updates of unknown type and anything
failing stay manual. Two parts implement it:

- `allow_auto_merge` is part of `config/repository-defaults.json`, so every
active repository allows auto-merge. The setting only permits auto-merge
requests; it merges nothing by itself, and rulesets still gate every merge.
- `.github/workflows/dependabot-auto-merge.yml` in this repository is a
reusable workflow. A component repository opts in with a thin caller,
pinned to a full commit SHA on this repository's `main`:

```yaml
name: Dependabot auto-merge

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]

permissions: {}

concurrency:
group: dependabot-auto-merge-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
auto-merge:
if: github.event.pull_request.user.login == 'dependabot[bot]'
uses: atrinik/github-settings/.github/workflows/dependabot-auto-merge.yml@<sha> # <tag>
permissions:
contents: write
pull-requests: write
```

The reusable workflow never checks out or runs pull-request code. Its
`classify` job has read-only access and uses the official
`dependabot/fetch-metadata` action, which verifies the pull request author and
the signed first commit. The job also requires that Dependabot authored every
commit and triggered the run. A pull request is eligible only when every
updated dependency is a `version-update:semver-patch` or
`version-update:semver-minor` update. A grouped pull request therefore
qualifies when its largest update is minor and no update has an unknown type
(for example a bare commit-SHA or digest change). Updates to
`dependabot/fetch-metadata` or to this repository's workflow always need a
person, because the pull request would otherwise classify itself with the new
code.

The `apply` job holds the write token and runs only `gh`. For an eligible pull
request it runs `gh pr merge --auto --squash` bound to the classified head
commit. For any other run on a Dependabot pull request it disables an earlier
auto-merge request: when Dependabot updates a grouped pull request to include a
major, when a person pushes to the branch (including **Update branch**), or
when classification fails. That pull request is then merged by hand; after a
transient failure, re-running the workflow restores the request. The disable
path also clears a request a person set on an ineligible pull request.

Required approvals stay at zero, so no bot approval is needed and Actions still
cannot approve pull requests. Required CI, review-thread resolution and the
strict up-to-date policy apply unchanged. Known limits:

- Merges are made with the workflow's `GITHUB_TOKEN`, so the resulting push to
`main` does not start `push` or `workflow_run` workflows. Main-branch checks,
releases and development deployments pick the change up with the next
merge by a person. Changing that needs a dedicated GitHub App credential and
a separate review.
- With strict required checks, an auto-merge request waits while the branch is
behind `main`. Dependabot may not rebase a branch that is only behind. Comment
`@dependabot rebase` to refresh a behind pull request: Dependabot pushes the
rebase itself, so it stays eligible. **Update branch** makes it manual.
- Repositories in the `merge_queue` inventory (`playtester`) are not rolled
out yet. A queue entry added with `GITHUB_TOKEN` may not start
`merge_group` checks; verify that live before adding a caller there.
- GitHub reports `0.x` minor bumps as `semver-minor`; they are eligible like
any other minor update and rely on required CI.

- Auto-merge removes the human read of release notes, lockfile changes and
provenance for eligible updates. That includes minor GitHub Actions updates,
whose code later runs on `main` with repository secrets; required CI is the
remaining gate. Whether `GITHUB_TOKEN` can merge an update that changes
`.github/workflows/` is verified by the pilot before rollout.

Opt in only repositories with required CI: on a pull request with no pending
required check, auto-merge merges at once. Roll out only after the publisher
has applied `allow_auto_merge`; before that, every eligible run fails at
`gh pr merge --auto`. Then add the caller in
a reviewed pull request in each repository, without editing its
`dependabot.yml`. A `pull_request` run uses the workflow files of the
pull request's merge with `main`, so open Dependabot pull requests pick up the
caller on their next update, and new ones from the start.

## Protecting and retiring a maintenance branch

Add a unique repository/branch entry to `maintenance_branches` in
Expand Down
1 change: 1 addition & 0 deletions bin/audit
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ for repository in "${!expected_active[@]}"; do
.allow_merge_commit == $desired[0].allow_merge_commit and
.allow_rebase_merge == $desired[0].allow_rebase_merge and
.allow_squash_merge == $desired[0].allow_squash_merge and
.allow_auto_merge == $desired[0].allow_auto_merge and
.delete_branch_on_merge == $desired[0].delete_branch_on_merge and
.squash_merge_commit_title == $desired[0].squash_merge_commit_title and
.squash_merge_commit_message == $desired[0].squash_merge_commit_message
Expand Down
3 changes: 2 additions & 1 deletion config/actions-selected.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"docker/login-action@*",
"docker/setup-buildx-action@*",
"docker/build-push-action@*",
"rust-lang/crates-io-auth-action@*"
"rust-lang/crates-io-auth-action@*",
"dependabot/fetch-metadata@*"
]
}
1 change: 1 addition & 0 deletions config/repository-defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"allow_merge_commit": false,
"allow_rebase_merge": false,
"allow_squash_merge": true,
"allow_auto_merge": true,
"delete_branch_on_merge": true,
"squash_merge_commit_title": "PR_TITLE",
"squash_merge_commit_message": "PR_BODY"
Expand Down
2 changes: 2 additions & 0 deletions tests/audit-fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,8 @@ def rulesets(repository):
payload['full_name'] = 'foreign/observatory'
if scenario == 'merge-drift' and target:
payload['allow_merge_commit'] = True
if scenario == 'auto-merge-drift' and target:
payload['allow_auto_merge'] = False
if scenario == 'secret-baseline' and target:
payload['security_and_analysis']['secret_scanning']['status'] = 'disabled'
else:
Expand Down
1 change: 1 addition & 0 deletions tests/audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ done

assert_drift unmanaged 'active repository is not in the desired-state inventory'
assert_drift merge-drift 'repository merge policy drift'
assert_drift auto-merge-drift 'repository merge policy drift'
assert_drift properties-drift 'repository custom-property drift'
assert_drift schema-drift 'organization custom-property schema drift'
assert_drift inventory-unreadable 'organization repository inventory could not be read'
Expand Down
Loading