diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 03f07bf..a6cfa01 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,23 @@ # Dependency updates for every ecosystem this repository ships. # -# Each ecosystem is ONE catch-all group covering minor, patch and major, with -# open-pull-requests-limit 1, so a week yields at most one pull request per -# ecosystem instead of one per dependency. Dependabot cannot group across -# ecosystems, so the number of entries below is the floor on weekly PRs. +# Each ecosystem is ONE catch-all group with open-pull-requests-limit 1, so a +# week yields at most one pull request per ecosystem instead of one per +# dependency. Dependabot cannot group ACROSS ecosystems, so the entries below +# are the floor on what Dependabot itself opens; .github/workflows/combine- +# deps.yml runs afterwards and folds whatever is open into a single pull +# request. # -# The github-actions entry exists because every action is pinned by commit SHA; -# nothing else would ever move those pins. +# The groups deliberately carry NO `update-types:` filter. That filter only +# recognises semver update types, so a Docker base image pinned by digest +# matches none of them and falls OUT of the group into its own ungrouped pull +# request, one per directory. Omitting the filter makes `patterns: "*"` mean +# what it says: everything, digests included. For the same reason majors are +# not split into a second group — that is a guaranteed extra pull request. +# +# Security updates keep their own group where one is defined: `applies-to` +# separates them from version updates and they are not capped by +# open-pull-requests-limit, because an urgent fix should not queue behind +# routine work. version: 2 updates: @@ -18,12 +29,7 @@ updates: groups: gomod: patterns: - - "*" - update-types: - - minor - - patch - - major - + - '*' - package-ecosystem: pip directory: /base/code schedule: @@ -32,12 +38,7 @@ updates: groups: pip: patterns: - - "*" - update-types: - - minor - - patch - - major - + - '*' - package-ecosystem: docker directory: /templates/builder schedule: @@ -46,12 +47,7 @@ updates: groups: docker: patterns: - - "*" - update-types: - - minor - - patch - - major - + - '*' - package-ecosystem: github-actions directory: / schedule: @@ -60,8 +56,4 @@ updates: groups: github-actions: patterns: - - "*" - update-types: - - minor - - patch - - major + - '*' diff --git a/.github/scripts/combine-deps.sh b/.github/scripts/combine-deps.sh new file mode 100755 index 0000000..f34855e --- /dev/null +++ b/.github/scripts/combine-deps.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# combine-deps — fold every open Dependabot pull request into a single one. +# +# Dependabot groups within an ecosystem but never across them, so even a +# correctly grouped repository still opens one pull request per ecosystem. +# This replays all of them onto one branch and closes the originals against it. +# +# Re-running is safe: the branch is rebuilt from the base branch every time, so +# a pull request that has since merged or closed simply drops out. +# +# A repository with artefacts that must be regenerated after a bump (a lockfile +# in a second module, a checked-in hash manifest) puts that in +# .github/scripts/combine-deps-local.sh, which runs after the replay and before +# the pull request is opened. Anything it commits rides along. +# +# One caveat before you close the combined pull request: closing a Dependabot +# pull request tells Dependabot not to re-open one for that same version. +# Merging moves the dependency, so the next pass proposes the next version and +# nothing is lost. Abandoning it instead forfeits that batch until a newer +# version ships. +set -euo pipefail + +BRANCH="deps/combined" +BASE="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" + +git fetch --quiet origin "$BASE" + +mapfile -t ROWS < <( + gh pr list --state open --author "app/dependabot" --base "$BASE" \ + --limit 100 --json number,title --jq '.[] | [.number, .title] | @tsv' | sort -n +) + +if [ "${#ROWS[@]}" -eq 0 ]; then + echo "combine-deps: no open Dependabot pull requests; nothing to combine." + exit 0 +fi + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +git checkout --quiet -B "$BRANCH" "origin/$BASE" + +combined=() +skipped=() + +for row in "${ROWS[@]}"; do + number="${row%%$'\t'*}" + title="${row#*$'\t'}" + + git fetch --quiet origin "refs/pull/${number}/head:refs/combine/${number}" + base_point="$(git merge-base "origin/$BASE" "refs/combine/${number}")" + + if git cherry-pick --allow-empty "${base_point}..refs/combine/${number}" >/dev/null 2>&1; then + combined+=("${number}"$'\t'"${title}") + echo "combine-deps: replayed #${number} — ${title}" + else + git cherry-pick --abort >/dev/null 2>&1 || true + skipped+=("${number}"$'\t'"${title}") + echo "combine-deps: SKIPPED #${number} (conflicts with an earlier bump) — ${title}" + fi +done + +if [ "${#combined[@]}" -eq 0 ]; then + echo "combine-deps: every pull request conflicted; leaving them all open." + exit 0 +fi + +if [ -x .github/scripts/combine-deps-local.sh ]; then + echo "combine-deps: running repository-local post-processing." + .github/scripts/combine-deps-local.sh +fi + +body_file="$(mktemp)" +{ + echo "Every open Dependabot pull request, replayed onto one branch by" + echo "\`.github/workflows/combine-deps.yml\` so the week's dependency work is" + echo "reviewed and merged once instead of per ecosystem." + echo + echo "### Combined" + echo + for row in "${combined[@]}"; do + echo "- #${row%%$'\t'*} — ${row#*$'\t'}" + done + if [ "${#skipped[@]}" -gt 0 ]; then + echo + echo "### Left open (conflicted on replay, merge these by hand)" + echo + for row in "${skipped[@]}"; do + echo "- #${row%%$'\t'*} — ${row#*$'\t'}" + done + fi +} > "$body_file" + +git push --quiet --force-with-lease origin "$BRANCH" + +existing="$(gh pr list --state open --head "$BRANCH" --json number --jq '.[0].number // empty')" +if [ -n "$existing" ]; then + gh pr edit "$existing" --body-file "$body_file" + combined_pr="$existing" +else + gh pr create --base "$BASE" --head "$BRANCH" \ + --title "build(deps): combined dependency updates" --body-file "$body_file" + combined_pr="$(gh pr list --state open --head "$BRANCH" --json number --jq '.[0].number')" +fi + +for row in "${combined[@]}"; do + number="${row%%$'\t'*}" + gh pr comment "$number" --body "Superseded by #${combined_pr}, which carries this bump together with the rest of the week's dependency updates." + gh pr close "$number" +done + +echo "combine-deps: combined ${#combined[@]} pull request(s) into #${combined_pr}." diff --git a/.github/workflows/combine-deps.yml b/.github/workflows/combine-deps.yml new file mode 100644 index 0000000..3b4f88e --- /dev/null +++ b/.github/workflows/combine-deps.yml @@ -0,0 +1,58 @@ +# Folds every open Dependabot pull request into a single one. +# +# Dependabot groups within an ecosystem but never across them (see the comment +# in .github/dependabot.yml), so its best case is one pull request per +# ecosystem. This runs after its weekly pass and combines whatever is open. +# +# REQUIRES the DEPS_COMBINE_TOKEN secret — a PAT (or GitHub App token) with +# `repo` scope. The built-in GITHUB_TOKEN is not usable here for two separate +# reasons: this organisation has "Allow GitHub Actions to create and approve +# pull requests" turned off, so it cannot open the combined pull request at +# all; and even with that enabled, a branch pushed or a pull request opened by +# GITHUB_TOKEN does not trigger workflow runs, so the combined pull request +# would sit with no CI and never go green. An organisation-level secret covers +# every repository at once. +name: combine-deps + +on: + schedule: + # Mondays, a few hours after Dependabot's weekly pass has opened its pull + # requests. Off the hour: :00 is the busiest minute on the shared runners. + - cron: '23 9 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: combine-deps + cancel-in-progress: false + +jobs: + combine: + name: Combine open Dependabot pull requests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # The secrets context is not available to `if:`, so this is checked in the + # step body rather than as a condition. + - name: Require a token that can open a pull request and trigger CI + env: + COMBINE_TOKEN: ${{ secrets.DEPS_COMBINE_TOKEN }} + run: | + if [ -z "$COMBINE_TOKEN" ]; then + echo "::error::DEPS_COMBINE_TOKEN is not set. Add it as an organisation secret (a PAT with 'repo' scope)." + echo "GITHUB_TOKEN cannot be used here: Actions is not permitted to create pull requests in" + echo "this organisation, and a pull request it opens does not trigger CI, so the combined" + echo "pull request would never go green." + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ secrets.DEPS_COMBINE_TOKEN }} + - name: Combine + env: + GH_TOKEN: ${{ secrets.DEPS_COMBINE_TOKEN }} + run: bash .github/scripts/combine-deps.sh