diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..b158189 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 40ab76f..c0b42ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 77c3f52..6031f14 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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@ # + 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 diff --git a/bin/audit b/bin/audit index 8aa9e3c..03b1a3c 100755 --- a/bin/audit +++ b/bin/audit @@ -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 diff --git a/config/actions-selected.json b/config/actions-selected.json index 33aac03..88012ab 100644 --- a/config/actions-selected.json +++ b/config/actions-selected.json @@ -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@*" ] } diff --git a/config/repository-defaults.json b/config/repository-defaults.json index ac53076..a158d7a 100644 --- a/config/repository-defaults.json +++ b/config/repository-defaults.json @@ -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" diff --git a/tests/audit-fixtures.py b/tests/audit-fixtures.py index db3b6fd..8e83cc6 100755 --- a/tests/audit-fixtures.py +++ b/tests/audit-fixtures.py @@ -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: diff --git a/tests/audit.sh b/tests/audit.sh index c1b0d0b..8201e0f 100755 --- a/tests/audit.sh +++ b/tests/audit.sh @@ -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'