Skip to content
Merged
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
17 changes: 14 additions & 3 deletions .github/workflows/automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
# allowed-update-types: "version-update:semver-patch"
83 changes: 80 additions & 3 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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 }}
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down