A collection of reusable GitHub Actions workflows (on: workflow_call) and composite
actions shared across WeMoveEU projects. Caller repositories reference these by path and
version tag from their own .github/workflows.
Pin to the floating major tag — the current major is @v14:
uses: WemoveEU/ci-workflows/.github/workflows/docker-build.yml@v14
uses: WemoveEU/ci-workflows/.github/actions/docker-smoke@v14Releases follow semver with a floating major (see RELEASING.md): each
release gets an immutable vMAJOR.MINOR.PATCH tag, and the floating vMAJOR tag is moved
to point at it. Consumers pinned to @v14 pick up backward-compatible minor/patch fixes
automatically; a breaking change cuts a new major (v15) that you opt into deliberately
(Dependabot opens the PR). Always pin the floating major, never a branch.
The workflows and actions available:
docker-build.yml— build and push a Docker image to GitHub Container Registry.deploy-strapi.yml— build and deploy a Strapi backend + frontend.notify.yml— post a Slack notification.python-build.yml— build a Python package withuv.dependabot-auto-merge.yml— auto-merge low-risk Dependabot PRs once required checks pass.docker-smoke— composite action: build the image, run it, and probe it with the production Host header.
Builds a Docker image and pushes it to GitHub Container Registry
(ghcr.io/<name>). This is the foundational workflow that the others build on. It
supports staging and production environments via its tagging strategy.
-
dockerfile: Path to the Dockerfile used for building the image. Default
Dockerfile. -
name: Name of the image in the registry (pushed to
ghcr.io/<name>). Default${{github.repository}}. -
dir: The root of app sources / build context. Use if your app is in a subdirectory, e.g.
frontend. Default.. -
overlay: Name of an artifact to unpack over the checked-out source before building (e.g. env files). Use a unique name based on
${{github.run_id}}to avoid races between concurrent runs. The artifact is deleted after it is unpacked. -
prepare: Shell commands run (via
bash) after unpacking the overlay, before building the image. -
args: Docker build args (one variable, or multiline format), passed to the
build-argsinput of docker/build-push-action. -
production_branch: The name of the production branch. Default
main.
- personal_token: Optional personal access token used to check out private repositories and submodules. Falls back to
github.tokenwhen not provided.
Tags are generated by docker/metadata-action from the git context:
feature/*branches →featuretag + the branch ref- Semver tags (
v1.2.3) →version/major.minor/major/releasetags - Push to
production_branch→latest - Push to the default branch when it is not the production branch →
staging
If the ref matches none of these rules (e.g. a release/* or otherwise
unrecognised branch), no tag is produced. In that case the image is built but
not pushed — the run still validates the Dockerfile, and a warning is emitted —
rather than failing with tag is needed when pushing to registry.
The release job runs on ubuntu-latest with contents: read, packages: write,
and actions: write permissions (the last is needed because the overlay artifact is
deleted via the Actions API). Its steps:
-
Checkout Code:
actions/checkoutclones the repository and its submodules (fetch-depth: 20). -
Apply Overlay: If
overlayis set,actions/download-artifactdownloads it andgeekyeggo/delete-artifactremoves it. Create the overlay in the caller withactions/upload-artifact; its files are unpacked into the project directory. See this example. -
Run Prepare Script: Executes the
prepareshell commands if provided. -
Generate Image Tag:
docker/metadata-actionproduces tags per the strategy above. -
Set up Docker Buildx:
docker/setup-buildx-action. -
Login to GitHub Container Registry:
docker/login-action(authenticates toghcr.iowithGITHUB_TOKEN). Skipped when no tag matched. -
Build and Push:
docker/build-push-actionbuilds fromdirusingdockerfileand pushes the generated tags (build-only when no tag matched).
name: Deploy
on:
push:
branches:
- main
- "release/*"
jobs:
release:
uses: WemoveEU/ci-workflows/.github/workflows/docker-build.yml@v14
with:
production_branch: ${{vars.CI_PRODUCTION_BRANCH}}
dockerfile: server/Dockerfile
dir: server
name: crm/serverOrchestrates a Strapi deploy by calling docker-build.yml twice — backend first
(backend/Dockerfile, image <repo>/backend), then frontend
(frontend/Dockerfile, image <repo>/frontend). Between them, a wait-for-backend
job polls the live backend until it is ready.
- backend_args: Docker build args for the backend image. Default
''. - frontend_args: Docker build args for the frontend image. Default
''. - production_branch: The name of the production branch. Default
main. - deploy_marker: Filename in the Strapi
publicfolder whose presence means the backend is deployed. The health check pollshttps://strapi.${{vars.DOMAIN}}/<deploy_marker>and only runs when this input is provided.
- personal_token: Optional personal access token, forwarded to
docker-build.ymlfor private repositories and submodules.
- The
wait-for-backendjob selects its GitHub Environment dynamically: production when on thevars.CI_PRODUCTION_BRANCHbranch or a tag starting withv; otherwise staging. - Overlay names are made run-unique with
${{github.run_id}}(backend_env_<run_id>,frontend_env_<run_id>) to avoid collisions between concurrent runs. - Calling repositories must define the repository variables
vars.CI_PRODUCTION_BRANCHandvars.DOMAIN.
Posts a Slack notification (green on success, red on failure) via
slackapi/slack-github-action (incoming-webhook mode). Call it as a final step from a
caller workflow.
- slack_webhook_url: Slack channel incoming-webhook URL (required).
Builds a Python package with uv: checks out the repo (actions/checkout),
installs uv (astral-sh/setup-uv), and runs uv build. Takes no inputs.
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.
Arming is retried five times, and falls back to a direct merge. That covers two real
failure modes: gh pr merge --auto intermittently returns a transient GraphQL error, and
auto-merge cannot be enabled at all on a PR whose required checks have already gone
green — which fast CI makes common, and which otherwise leaves the PR sitting green and
unmerged. The direct merge is not a bypass: branch protection still refuses it while
required checks are pending or failing.
-
merge-method:
merge|squash|rebase. Defaultsquash. -
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.
-
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_TOKENhas its push event suppressed by GitHub's recursion guard, so a deploy that runson: pushto the default branch never runs — the PR goes green and nothing ships. Omit them and the workflow falls back toGITHUB_TOKENand warns in the run log.The App needs two repository permissions: Contents (read/write) and Pull requests (read/write); Metadata (read) is mandatory and comes along. It does not need Workflows (write) — that permission governs pushing workflow files through the Contents API, not merging a PR that changes them.
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.
Composite action (.github/actions/docker-smoke) that builds the repo's Docker image,
runs the container, and verifies it actually serves an HTTP request sent with the
production Host header. This catches two classes of bug a plain build check cannot:
image-build breaks (e.g. a base-image bump dropping a tool the Dockerfile needs), and
runtime serving breaks that only appear under the real hostname (e.g. a preview server's
allowedHosts rejecting an unlisted Host with 403).
Use it as a step inside the job whose name is the required status check (ci under the
org ruleset) so the context stays exactly that name (a reusable workflow would produce
ci / <job> and break the required-check match).
With smoke-command it also runs a deeper check against the running container, and with
fail-on-log-pattern it fails on server log lines that a page can swallow while still
answering 200. Whenever a step fails, the last 200 lines of the container log are printed.
- host: Host header to send — the production hostname the app is served under. Required.
- port: Container port to publish and probe. Default
8080. - path: Request path to probe. Default
/. - expected-status: HTTP status code the probe must return. Default
200. - dockerfile: Path to the Dockerfile. Default
Dockerfile. - context: Docker build context. Default
.. - startup-timeout: Seconds to wait for the container to start responding. Default
90. - build-only: When
"true", only build the image and skip running/probing it. Use for apps that can't boot standalone in CI (need runtime secrets, a database, etc.). Default"false". - smoke-command: Shell command run with bash after the probe succeeds, from the workspace root, with
SMOKE_BASE_URL=http://localhost:<port>in its environment. A non-zero exit fails the action. Default""(skipped). - fail-on-log-pattern: Extended regex (
grep -E). After the probe andsmoke-command, the action fails if the container log matches it, e.g."MODULE_NOT_FOUND|Cannot find module". Default""(skipped).
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: WemoveEU/ci-workflows/.github/actions/docker-smoke@v14
with:
host: app.example.org
port: "3000"With a smoke script and a log check (a Next.js frontend in frontend/):
- uses: WeMoveEU/ci-workflows/.github/actions/docker-smoke@v14
with:
dockerfile: frontend/Dockerfile
context: frontend
host: example.org
port: "3000"
path: /en
smoke-command: node frontend/scripts/smoke-test.mjs "$SMOKE_BASE_URL"
fail-on-log-pattern: "MODULE_NOT_FOUND|Cannot find module"Version tags v1–v13 predate the current scheme and are frozen point releases. The
floating-major convention (see RELEASING.md) starts at v14.
- v14.8 —
docker-smoke: optionalsmoke-command(runs after the probe withSMOKE_BASE_URLset; its failure fails the action) andfail-on-log-pattern(fails when the container log matches an extended regex). The last 200 lines of the container log are now printed when any step fails, not only the probe. Existing callers are unaffected. - v14.6 —
pin-overrideaction: a first pass re-resolves every alerted package inside the ranges the tree already declares (yarn up -R <pkg>, lockfile only, manifest untouched) before any resolution is written. This is what clears a package installed at several versions, which Dependabot cannot fix, and a fix a hand-closed Dependabot PR taught Dependabot to ignore. Measured across the fleet on 2026-09-11: 220 audit findings to 102. Newbumpedoutput;changedis true when either pass changed something. - v14.5 —
pin-overridecomposite action: writes Yarnresolutionsfor the transitive advisories Dependabot cannot fix when a parent pins the vulnerable package exactly. - v14.2 —
dependabot-auto-merge.yml: arming is now retried (5 attempts) with a direct-merge fallback, so a transient GraphQL error no longer strands a PR and an already-green PR still merges. Consolidates logic that existed only in the inline copies in wemove.eu, youmove, wemove-charity.eu and pubstatic, which this lets them drop. - v14.1 —
dependabot-auto-merge.yml: accepts optionalapp-id/app-private-keysecrets so the merge is armed with a GitHub App token and fires a real push event (aGITHUB_TOKEN-armed merge does not, leaving push-triggered deploys silently unrun). Falls back toGITHUB_TOKENwhen the secrets are omitted, and warns. The update-type gate is now a whole-token, fail-closed match — previously an emptyupdate-typefromfetch-metadatasatisfiedcontains()and could auto-merge a major. - v14 — First release under the semver + floating-major scheme. Ships the
docker-smokecomposite action (build + run + production-Host-header probe, with abuild-onlymode). - 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 withtag is needed when pushing to registry. Registry login is skipped in that case. - v11 — README rewritten as a repo overview covering all four workflows; action versions corrected.