From 4cb9e8713733600a762f7d8b4b3451c004fd587d Mon Sep 17 00:00:00 2001 From: Lucas Ramos <3140800+lucas-d-ramos@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:01:16 +0200 Subject: [PATCH] dependabot-auto-merge: optional App token, fail-closed update-type gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-merge armed with GITHUB_TOKEN lands with its push event suppressed (GitHub's recursion guard), so a repo whose deploy runs `on: push` to the default branch builds nothing for that merge. Surveyed across the org on 2026-08-20: nine repos had silently accumulated undeployed commits on main this way — youmove 41, wemove-charity.eu 40, bankimporter 32, mep-data-manager 28, toolbox 22 — every one merged by app/github-actions. Accept optional `app-id` / `app-private-key` secrets and arm the merge with an App installation token instead, which emits a real push event. The secrets are optional and fall back to GITHUB_TOKEN, so existing callers are unaffected; the workflow warns in the run log when it falls back. Also make the update-type gate fail closed. `contains(allowed, update-type)` returned true whenever fetch-metadata produced no update-type, because every string contains the empty string — which would auto-merge a major. Match whole tokens and require a non-empty update-type, the same stance donation-form and donations-api take. This matters because dependabot.yml `ignore` rules cover version updates only: a security major does reach this workflow, and this gate is the only thing refusing it. Co-Authored-By: Claude Opus 5 --- .github/workflows/automerge.yml | 17 ++++- .github/workflows/dependabot-auto-merge.yml | 83 ++++++++++++++++++++- README.md | 62 +++++++++++++++ 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 57058c4..9bff32f 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -8,8 +8,16 @@ name: Dependabot auto-merge # GITHUB_TOKEN is passed to the reusable automatically (no `secrets: inherit`). # # Pin the reusable to a released tag of ci-workflows. v13 is the first tag that -# contains it. Dependabot's github-actions ecosystem will open PRs bumping this -# ref as new tags are cut — which the auto-merge this enables will then land. +# contains it; v14.1 is the first that accepts the App secrets below. +# Dependabot's github-actions ecosystem will open PRs bumping this ref as new +# tags are cut — which the auto-merge this enables will then land. +# +# The `secrets:` block is what makes the merge fire a push event. Without it +# the merge is armed with GITHUB_TOKEN and GitHub suppresses the push, so a +# deploy that runs `on: push` to the default branch never runs — the PR goes +# green and nothing ships. Drop the block only for repos where merging is not +# meant to trigger anything (the reusable warns in the run log when it is +# missing). on: pull_request: @@ -22,6 +30,9 @@ jobs: auto-merge: if: github.actor == 'dependabot[bot]' uses: WeMoveEU/ci-workflows/.github/workflows/dependabot-auto-merge.yml@v14 + secrets: + app-id: ${{ secrets.DEPENDABOT_POLICY_APP_ID }} + app-private-key: ${{ secrets.DEPENDABOT_POLICY_APP_KEY }} # Override defaults if desired, e.g. patch-only for runtime-heavy repos: # with: - # allowed-update-types: "version-update:semver-patch" \ No newline at end of file + # allowed-update-types: "version-update:semver-patch" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index ba3a001..d440934 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -9,6 +9,30 @@ name: Dependabot auto-merge # the consuming repo. Without that ruleset, `gh pr merge --auto` has nothing to # wait for. Major bumps and anything outside allowed-update-types are left for # manual review. +# +# --------------------------------------------------------------------------- +# Arming credential — pass the App secrets whenever the merge must trigger +# something downstream. +# +# A merge armed with GITHUB_TOKEN lands with its push event SUPPRESSED (this is +# GitHub's recursion guard: events raised by GITHUB_TOKEN do not start new +# workflow runs). For a repo whose deploy runs `on: push` to the default +# branch, that means the merge is green but no image is ever built. Measured +# across this org on 2026-08-20: nine repos had accumulated undeployed commits +# on `main` this way — youmove 41, wemove-charity.eu 40, bankimporter 32, +# mep-data-manager 28, toolbox 22 — while every merged Dependabot PR showed +# `mergedBy: app/github-actions`. +# +# Passing `app-id` + `app-private-key` mints an installation token instead, and +# merges armed with it emit real push events. Omit them and the workflow falls +# back to GITHUB_TOKEN, so existing callers keep working unchanged — but any +# caller that deploys on push wants the App. +# +# The App needs three repository permissions: Contents (read/write), +# Pull requests (read/write), and Workflows (read/write). Workflows is easy to +# miss and not optional: Dependabot's `github-actions` PRs edit files under +# .github/workflows/, and a token without it is refused when merging them. +# --------------------------------------------------------------------------- on: workflow_call: @@ -18,9 +42,21 @@ on: type: string default: "squash" allowed-update-types: - description: "Comma-separated Dependabot update-types to auto-merge" + description: >- + Comma-separated Dependabot update-types to auto-merge. Exact tokens, + no spaces around the commas — matching is whole-token, not substring. type: string default: "version-update:semver-patch,version-update:semver-minor" + secrets: + app-id: + description: >- + App ID of a GitHub App installed on the calling repo. Optional; + without it the merge is armed with GITHUB_TOKEN and fires no push + event. Required together with app-private-key. + required: false + app-private-key: + description: "Private key (PEM) for the App named by app-id." + required: false permissions: contents: write @@ -37,9 +73,50 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} + # The `secrets` context is not usable in a step-level `if:`, so resolve + # "did the caller pass an App?" into an output first. + - name: Detect App credentials + id: cfg + env: + APP_ID: ${{ secrets.app-id }} + run: | + if [ -n "$APP_ID" ]; then + echo "has-app=true" >> "$GITHUB_OUTPUT" + else + echo "has-app=false" >> "$GITHUB_OUTPUT" + echo "::warning::No App credentials passed — arming with GITHUB_TOKEN, whose merge fires no push event. A deploy that runs on push to the default branch will NOT run for this merge." + fi + + # The update-type gate, used by this step and the merge below. Whole-token + # match, fail-closed on an empty update-type: the bare + # `contains(allowed, update-type)` this replaced returned TRUE whenever + # fetch-metadata produced no update-type (every string contains the empty + # string), which would have auto-merged a major. Same fail-closed stance + # as donation-form / donations-api — and it matters, because dependabot.yml + # `ignore` rules cover version updates only, so a *security* major does + # reach this workflow and this gate is the only thing refusing it. + # + # Token is minted only when the PR would actually merge, so a skipped PR + # costs nothing. + - name: Mint App token + id: app-token + if: >- + steps.cfg.outputs.has-app == 'true' && + steps.meta.outputs.update-type != '' && + contains(format(',{0},', inputs.allowed-update-types), + format(',{0},', steps.meta.outputs.update-type)) + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.app-private-key }} + - name: Enable auto-merge for allowed update types - if: contains(inputs.allowed-update-types, steps.meta.outputs.update-type) + if: >- + steps.meta.outputs.update-type != '' && + contains(format(',{0},', inputs.allowed-update-types), + format(',{0},', steps.meta.outputs.update-type)) run: gh pr merge --auto --${{ inputs.merge-method }} "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Falls back to GITHUB_TOKEN when the App token step was skipped. + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index a69bf49..2d6a492 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The workflows and actions available: - [`deploy-strapi.yml`](#deploy-strapiyml) — build and deploy a Strapi backend + frontend. - [`notify.yml`](#notifyyml) — post a Slack notification. - [`python-build.yml`](#python-buildyml) — build a Python package with `uv`. +- [`dependabot-auto-merge.yml`](#dependabot-auto-mergeyml) — auto-merge low-risk Dependabot PRs once required checks pass. - [`docker-smoke`](#docker-smoke) — composite action: build the image, run it, and probe it with the production Host header. --- @@ -155,6 +156,66 @@ installs uv (`astral-sh/setup-uv`), and runs `uv build`. Takes no inputs. --- +## dependabot-auto-merge.yml + +Enables auto-merge on Dependabot PRs for low-risk update types. Call it from a caller +workflow triggered `on: pull_request`; the reusable only acts on PRs authored by +`dependabot[bot]`. + +Auto-merge only *completes* once the target branch's required status checks pass, so this +depends on a branch ruleset (require a PR + required checks) on the consuming repo. +Without that ruleset `gh pr merge --auto` has nothing to wait for and the PR merges +immediately. + +### Inputs + +- **merge-method**: `merge` | `squash` | `rebase`. Default `squash`. + +- **allowed-update-types**: Comma-separated Dependabot update-types to auto-merge, matched + as whole tokens (no spaces around the commas). Default + `version-update:semver-patch,version-update:semver-minor`. Anything outside the list — + majors included — is left for manual review. + +### Secrets + +- **app-id** / **app-private-key**: Optional GitHub App credentials used to arm the merge. + **Pass these whenever merging is meant to trigger something downstream.** A merge armed + with `GITHUB_TOKEN` has its push event suppressed by GitHub's recursion guard, so a + deploy that runs `on: push` to the default branch never runs — the PR goes green and + nothing ships. Omit them and the workflow falls back to `GITHUB_TOKEN` and warns in the + run log. + + The App needs three repository permissions: **Contents** (read/write), **Pull requests** + (read/write) and **Workflows** (read/write). Workflows is easy to miss and not optional: + Dependabot's `github-actions` PRs edit files under `.github/workflows/`, and a token + without it is refused when merging them. + +### Sample usage + +```yaml +name: Dependabot auto-merge + +on: + pull_request: + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: github.actor == 'dependabot[bot]' + uses: WeMoveEU/ci-workflows/.github/workflows/dependabot-auto-merge.yml@v14 + secrets: + app-id: ${{ secrets.DEPENDABOT_POLICY_APP_ID }} + app-private-key: ${{ secrets.DEPENDABOT_POLICY_APP_KEY }} +``` + +The caller's `permissions:` block must grant `contents: write` and `pull-requests: write` — +a called workflow's effective `GITHUB_TOKEN` permissions are capped by the caller's. + +--- + ## docker-smoke Composite action (`.github/actions/docker-smoke`) that builds the repo's Docker image, @@ -200,6 +261,7 @@ jobs: Version tags `v1`–`v13` predate the current scheme and are frozen point releases. The floating-major convention (see [`RELEASING.md`](RELEASING.md)) starts at **`v14`**. +- **v14.1** — `dependabot-auto-merge.yml`: accepts optional `app-id` / `app-private-key` secrets so the merge is armed with a GitHub App token and fires a real push event (a `GITHUB_TOKEN`-armed merge does not, leaving push-triggered deploys silently unrun). Falls back to `GITHUB_TOKEN` when the secrets are omitted, and warns. The update-type gate is now a whole-token, fail-closed match — previously an empty `update-type` from `fetch-metadata` satisfied `contains()` and could auto-merge a major. - **v14** — First release under the semver + floating-major scheme. Ships the `docker-smoke` composite action (build + run + production-Host-header probe, with a `build-only` mode). - **v13** — Adds a reusable Dependabot auto-merge workflow and an actionlint CI gate. - **v12** — `docker-build.yml`: when no tag rule matches the ref, the image is now built without pushing (with a warning) instead of failing with `tag is needed when pushing to registry`. Registry login is skipped in that case.