Reusable Workflows in GitHub Actions sauber bauen

Written in

von

In fast jedem Repository, das über ein paar Wochen hinaus lebt, sieht die Pipeline irgendwann gleich aus: build, test, scan, deploy. Und in fast jedem zweiten Repository steht dieser Ablauf noch einmal komplett neu getippt in der Workflow-Datei. Das ist der Punkt, an dem Reusable Workflows sinnvoll werden. Sie sind nicht neu, aber sie werden oft falsch oder gar nicht eingesetzt, und dann wird aus der guten Idee ein zentraler Klotz, den keiner mehr anfassen will.

Reusable Workflow oder Composite Action

Zuerst die Abgrenzung, weil hier die meisten Missverständnisse entstehen. Eine Composite Action bündelt mehrere Steps zu einem Step. Sie läuft im Job des Aufrufers, teilt sich dessen Runner und dessen Kontext. Ein Reusable Workflow bündelt dagegen ganze Jobs. Er wird per workflow_call aufgerufen, bekommt eigene Runner und eigene Job-Grenzen.

Die Faustregel: geht es um eine Abfolge von Kommandos innerhalb eines Jobs, nimm eine Composite Action. Geht es um einen kompletten Ablauf mit eigener Runner-Zuweisung, eigenen Permissions und mehreren Jobs, nimm einen Reusable Workflow. Wer das vermischt, baut sich Reusable Workflows mit genau einem Job, der drei Zeilen ausführt, und wundert sich über die Wartezeit beim Runner-Start.

Der Aufruf

Ein Reusable Workflow deklariert seine Schnittstelle über on: workflow_call. Alles, was der Aufrufer setzen kann, steht dort explizit. Das ist der eigentliche Wert: die Datei hat einen Vertrag.

# .github/workflows/build.yml (im zentralen Repo)
name: Build
on:
workflow_call:
inputs:
node-version:
type: string
default: "20"
run-tests:
type: boolean
default: true
secrets:
REGISTRY_TOKEN:
required: false
outputs:
image-digest:
description: "Digest des gebauten Images"
value: ${{ jobs.build.outputs.digest }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- if: ${{ inputs.run-tests }}
run: npm test
- id: build
run: echo "digest=$(./build.sh)" >> "$GITHUB_OUTPUT"

Der Aufruf ist dann kurz. Wichtig: der Reusable Workflow steht auf Job-Ebene, nicht auf Step-Ebene.

# .github/workflows/ci.yml (im Produkt-Repo)
name: CI
on: [push]
jobs:
build:
uses: meine-org/ci-workflows/.github/workflows/build.yml@v1
with:
node-version: "22"
secrets:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploye ${{ needs.build.outputs.image-digest }}"

Auf einen Tag pinnen, nicht auf main

Der häufigste Fehler ist der Verweis auf @main. Damit hängt jedes aufrufende Repository an dem, was gerade im zentralen Repo gemergt wurde. Ein Fehler dort legt alle Pipelines gleichzeitig still, und niemand hat etwas geändert. Wer das einmal an einem Freitagnachmittag erlebt hat, pinnt danach.

Für interne Workflows reicht ein beweglicher Major-Tag wie @v1, der bewusst weitergeschoben wird. Bei Workflows aus fremden Organisationen gilt dasselbe wie bei Actions: auf den vollen Commit-SHA pinnen. Ein Tag ist verschiebbar, ein SHA nicht. Ich hatte das Thema hier schon im Beitrag über GitHub Actions und Pinning, und für Reusable Workflows ändert sich an der Logik nichts.

Permissions werden nicht vererbt, sondern begrenzt

Ein Reusable Workflow kann nie mehr Rechte bekommen, als der aufrufende Workflow hat. Das GITHUB_TOKEN des Aufrufers wird durchgereicht, und die permissions im aufgerufenen Workflow können den Umfang nur einschränken, nicht erweitern. Das ist eine gute Eigenschaft, aber sie führt zu Verwirrung, wenn der zentrale Workflow plötzlich packages: write braucht und der Aufrufer davon nichts weiß.

Sauber ist: der zentrale Workflow deklariert pro Job das Minimum, das er wirklich braucht. Der Aufrufer setzt seinerseits ein enges permissions-Block. Was fehlt, fällt beim ersten Lauf auf, nicht drei Monate später bei einem Audit.

jobs:
build:
permissions:
contents: read
packages: write
id-token: write # nur wenn OIDC wirklich gebraucht wird
uses: meine-org/ci-workflows/.github/workflows/build.yml@v1

Grenzen, die man vorher kennen sollte

Reusable Workflows lassen sich schachteln, aber nur bis zu einer Tiefe von vier Ebenen. Das klingt großzügig und ist es in der Praxis auch, trotzdem sollte man nicht mehr als zwei Ebenen bauen. Wer bei Ebene drei ankommt, sucht meist einen Fehler in einer Datei, die er noch gar nicht gefunden hat.

Weiter: In einem aufrufenden Job kann neben uses kein steps stehen. Der Job ist entweder ein Aufruf oder eigene Steps, nicht beides. Und strategy: matrix funktioniert am Aufruf, wird aber innerhalb des Reusable Workflows nicht an dessen Jobs weitergegeben. Das ist der Punkt, an dem viele Leute anfangen, Matrix-Werte als Input durchzuschleifen. Das geht, wird aber schnell unleserlich.

Outputs müssen explizit durchgereicht werden, zweimal: vom Step zum Job über GITHUB_OUTPUT, und vom Job zum workflow_call-Output. Wer eine der beiden Ebenen vergisst, bekommt einen leeren String und keinen Fehler. Das ist eine der undankbarsten Fehlersuchen in Actions.

Versionierung ernst nehmen

Sobald mehr als eine Handvoll Repositories denselben Workflow aufruft, ist das zentrale Repo ein Produkt mit Nutzern. Ein neuer Pflicht-Input ohne Default ist ein Breaking Change. Ein umbenannter Output ist ein Breaking Change. Das gehört in einen Major-Tag, nicht in einen stillen Push auf v1.

Praktisch hat sich bewährt: v1, v2 als bewegliche Major-Tags, dazu unveränderliche v1.4.2-Tags für alle, die exakt pinnen wollen. Änderungen am beweglichen Tag laufen erst durch ein Test-Repo, das alle Aufrufvarianten abdeckt. Das ist Aufwand, aber deutlich weniger als ein Vormittag, an dem 30 Pipelines gleichzeitig rot sind.

Wann es sich nicht lohnt

Bei drei Repositories mit unterschiedlichen Stacks lohnt sich das nicht. Dann ist der zentrale Workflow eine Sammlung von if-Bedingungen, und die Duplikation wäre ehrlicher gewesen. Reusable Workflows zahlen sich dort aus, wo derselbe Ablauf tatsächlich derselbe ist, und wo eine Änderung an einer Stelle für alle gelten soll. Wer sie nur einsetzt, um YAML-Zeilen zu zählen, tauscht sichtbare Duplikation gegen unsichtbare Kopplung.

Wie sich das in einen größeren Rahmen aus Nachvollziehbarkeit und Freigabeprozessen einordnet, schreibe ich drüben auf digital-business.blog. Hier bleibt es beim Handwerk.

Hinterlasse einen Kommentar

Diese Seite verwendet Akismet, um Spam zu reduzieren. Erfahre, wie deine Kommentardaten verarbeitet werden..