From f99a5fa621ad6301f2fe09d93db2ffb70e5531ef Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Thu, 22 Oct 2026 14:58:11 -0500 Subject: [PATCH 1/3] feat(actions): auto-merge Dependabot patch and minor updates Add a reusable workflow that enables auto-merge for Dependabot pull requests whose every update is a semver patch or minor update, and disables it for anything else. Allow auto-merge in every active repository and allow dependabot/fetch-metadata in the Actions allowlist. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dependabot-auto-merge.yml | 141 ++++++++++++++++++++ AGENTS.md | 7 + README.md | 82 +++++++++++- bin/audit | 1 + config/actions-selected.json | 3 +- config/repository-defaults.json | 1 + 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..4936da9 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,141 @@ +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. + authors=$(gh api --paginate \ + "repos/${REPOSITORY}/pulls/${PULL_REQUEST}/commits?per_page=100" \ + --jq '.[] | (.author.login // "unknown")' | sort -u) + 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 // "") 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 + 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 }} + 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 ]]; 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..8f5e15e 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,82 @@ 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: + 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, or when a person pushes to the branch (including **Update branch**). +That pull request is then merged by hand. + +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 rebases its own pull requests; pressing + **Update branch** instead makes the pull request manual, as described above. +- GitHub reports `0.x` minor bumps as `semver-minor`; they are eligible like + any other minor update and rely on required CI. + +To roll out to a repository, add the caller in a reviewed pull request in that +repository. Do not edit its `dependabot.yml` for this. The `pull_request` +trigger runs the caller from the pull request's head, so a Dependabot pull +request is handled once its branch contains the caller: new pull requests +after the caller merges, and open ones after Dependabot next rebases them. + ## 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" From 76b6efe2b1d032c01ded05b41ddff048bfbe6ce6 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Thu, 22 Oct 2026 15:13:18 -0500 Subject: [PATCH 2/3] fix(actions): disable stale auto-merge when classification fails Run the apply job after a failed classification, fail closed when the commit list cannot be read, compare excluded names case-insensitively, restrict the caller template to main, test auto-merge drift in the audit, and correct the rollout, rebase and merge-queue notes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dependabot-auto-merge.yml | 14 ++++++++---- README.md | 24 +++++++++++++-------- tests/audit-fixtures.py | 2 ++ tests/audit.sh | 1 + 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 4936da9..b158189 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -76,9 +76,11 @@ jobs: fi # fetch-metadata verifies only the first commit; require all of them. - authors=$(gh api --paginate \ + if ! authors=$(gh api --paginate \ "repos/${REPOSITORY}/pulls/${PULL_REQUEST}/commits?per_page=100" \ - --jq '.[] | (.author.login // "unknown")' | sort -u) + --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 @@ -92,7 +94,7 @@ jobs: all(.[]; (.updateType | IN("version-update:semver-patch", "version-update:semver-minor")) and - ((.dependencyName // "") as $name | + ((.dependencyName // "" | ascii_downcase) as $name | ($name == "dependabot/fetch-metadata" or ($name | startswith("atrinik/github-settings"))) | not)) ' <<<"${UPDATES:-[]}" >/dev/null; then @@ -109,6 +111,9 @@ jobs: 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: @@ -119,12 +124,13 @@ jobs: 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 ]]; then + 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}" diff --git a/README.md b/README.md index 8f5e15e..080678c 100644 --- a/README.md +++ b/README.md @@ -1027,6 +1027,7 @@ failing stay manual. Two parts implement it: on: pull_request: + branches: [main] types: [opened, synchronize, reopened] permissions: {} @@ -1061,8 +1062,8 @@ 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, or when a person pushes to the branch (including **Update branch**). -That pull request is then merged by hand. +major, when a person pushes to the branch (including **Update branch**), or +when classification fails. That pull request is then merged by hand. 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 @@ -1074,16 +1075,21 @@ strict up-to-date policy apply unchanged. Known limits: 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 rebases its own pull requests; pressing - **Update branch** instead makes the pull request manual, as described above. + behind `main`. Dependabot rebases automatically only on conflicts. 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. -To roll out to a repository, add the caller in a reviewed pull request in that -repository. Do not edit its `dependabot.yml` for this. The `pull_request` -trigger runs the caller from the pull request's head, so a Dependabot pull -request is handled once its branch contains the caller: new pull requests -after the caller merges, and open ones after Dependabot next rebases them. +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 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' From ad9da89a3ac583031401c75a4d211b8dcc7d2621 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Thu, 22 Oct 2026 15:17:49 -0500 Subject: [PATCH 3/3] docs: clarify Dependabot auto-merge recovery and accepted risks Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 080678c..6031f14 100644 --- a/README.md +++ b/README.md @@ -1063,7 +1063,9 @@ 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. +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 @@ -1075,7 +1077,7 @@ strict up-to-date policy apply unchanged. Known limits: 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 rebases automatically only on conflicts. Comment + 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 @@ -1084,8 +1086,16 @@ strict up-to-date policy apply unchanged. Known limits: - GitHub reports `0.x` minor bumps as `semver-minor`; they are eligible like any other minor update and rely on required CI. -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 +- 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