Workflows wachsen. Was mit 20 Zeilen YAML beginnt, wird schnell zu 200 Zeilen. Und dann existiert derselbe Workflow in zehn Repositories – alle leicht unterschiedlich, alle manuell gepflegt. GitHub Actions bietet zwei mächtige Mechanismen für Wiederverwendbarkeit: Reusable Workflows und Custom Actions.
Dieses Kapitel zeigt, wie man wiederverwendbare Komponenten baut, wann man welchen Ansatz wählt und wie man Third-Party-Actions sicher einsetzt. Am Ende haben wir eine zentrale Workflow-Bibliothek für badge-gen und verstehen, warum SHA-Pinning keine optionale Sicherheitsmaßnahme ist.
Ein Reusable Workflow ist ein vollständiger Workflow, der von anderen Workflows aufgerufen werden kann. Der aufrufende Workflow heißt Caller, der aufgerufene Called Workflow.
Kernidee: Statt denselben CI-Workflow in 20 Repositories zu kopieren, definiert man ihn einmal in einem zentralen Repository und ruft ihn von überall auf.
Ein Workflow wird wiederverwendbar durch workflow_call
im on-Block:
# .github/workflows/ci-python.yml (im shared-workflows Repository)
name: Python CI
on:
workflow_call:
inputs:
python-version:
required: false
type: string
default: '3.12'
run-lint:
required: false
type: boolean
default: true
secrets:
codecov-token:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
- name: Install dependencies
run: pip install -e .[dev]
- name: Lint
if: ${{ inputs.run-lint }}
run: |
ruff check .
mypy src/
- name: Test
run: pytest --cov=src
- name: Upload coverage
if: ${{ secrets.codecov-token != '' }}
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.codecov-token }}Wichtige Elemente:
workflow_call: Macht den Workflow aufrufbarinputs: Parameter die der Caller übergeben kann
(string, number, boolean)secrets: Secrets die der Caller durchreichen kann# .github/workflows/ci.yml (im badge-gen Repository)
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
uses: my-org/shared-workflows/.github/workflows/ci-python.yml@main
with:
python-version: '3.12'
run-lint: true
secrets:
codecov-token: ${{ secrets.CODECOV_TOKEN }}Syntax:
uses: owner/repo/.github/workflows/workflow.yml@ref
Referenz-Optionen:
@main – Branch (flexibel, aber riskant)@v1.0.0 – Tag (empfohlen für Stabilität)@abc123def... – Commit SHA (maximale Sicherheit)Input-Typen:
| Typ | Beschreibung | Beispiel |
|---|---|---|
string |
Text | '3.12', 'production' |
number |
Zahl | 5, 100 |
boolean |
Wahrheitswert | true, false |
Secrets durchreichen:
# Einzelne Secrets
secrets:
deploy-key: ${{ secrets.DEPLOY_KEY }}
api-token: ${{ secrets.API_TOKEN }}
# Alle Secrets (Convenience)
secrets: inheritsecrets: inherit reicht
alle Secrets des Callers durch – praktisch, aber
weniger explizit.
Reusable Workflows können Daten an den Caller zurückgeben:
# Called Workflow
on:
workflow_call:
outputs:
version:
description: "Calculated version"
value: ${{ jobs.build.outputs.version }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- id: version
run: echo "version=1.2.3" >> $GITHUB_OUTPUT# Caller Workflow
jobs:
build:
uses: my-org/shared-workflows/.github/workflows/build.yml@main
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying version ${{ needs.build.outputs.version }}"| Limit | Wert |
|---|---|
| Verschachtelungstiefe | 10 Ebenen |
| Unique Reusable Workflows pro Datei | 50 |
| Secrets-Vererbung | Nur an direkt aufgerufene Workflows |
Wichtig: Environment-Variablen (env:)
werden nicht an Called Workflows vererbt. Daten müssen
explizit als inputs übergeben werden.
✅ Gut geeignet für:
❌ Weniger geeignet für:
| Typ | Läuft in | Geschwindigkeit | Plattform | Use-Case |
|---|---|---|---|---|
| Composite | Runner direkt | ⚡ Schnell | Alle | Steps bündeln |
| JavaScript | Node.js Runtime | ⚡ Schnell | Alle | Komplexe Logik, API-Calls |
| Docker | Container | 🐢 Langsamer | Nur Linux | Spezifische Dependencies |
Eine Composite Action bündelt mehrere Steps in eine wiederverwendbare Einheit.
Struktur:
.github/
actions/
setup-python-env/
action.yml
action.yml:
name: 'Setup Python Environment'
description: 'Install Python, dependencies and tools'
inputs:
python-version:
description: 'Python version to use'
required: false
default: '3.12'
install-dev:
description: 'Install dev dependencies'
required: false
default: 'true'
outputs:
cache-hit:
description: 'Whether cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Cache pip
id: cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
- name: Install dependencies
shell: bash
run: |
pip install --upgrade pip
if [[ "${{ inputs.install-dev }}" == "true" ]]; then
pip install -e .[dev]
else
pip install -e .
fiVerwendung (lokal):
steps:
- uses: actions/checkout@v4
- name: Setup environment
uses: ./.github/actions/setup-python-env
with:
python-version: '3.11'
install-dev: 'true'Verwendung (remote):
steps:
- uses: my-org/shared-actions/setup-python-env@v1
with:
python-version: '3.11'1. Shell muss explizit angegeben werden:
- run: echo "Hello"
shell: bash # Pflicht bei Composite Actions!2. Inputs sind immer Strings:
# ❌ Funktioniert nicht wie erwartet
if: ${{ inputs.install-dev }} # Immer truthy weil String
# ✅ Korrekt
if: ${{ inputs.install-dev == 'true' }}3. Outputs müssen gemapped werden:
outputs:
result:
value: ${{ steps.compute.outputs.result }} # Mapping erforderlichFür komplexere Logik: JavaScript Actions mit dem GitHub Actions Toolkit.
Struktur:
my-action/
action.yml
index.js
package.json
dist/
index.js # Kompiliertes Bundle
action.yml:
name: 'Badge Generator'
description: 'Generate SVG badges from metrics'
inputs:
metric-name:
description: 'Name of the metric'
required: true
value:
description: 'Value to display'
required: true
color:
description: 'Badge color'
required: false
default: 'blue'
outputs:
badge-path:
description: 'Path to generated badge'
runs:
using: 'node20'
main: 'dist/index.js'index.js:
const core = require('@actions/core');
const github = require('@actions/github');
const fs = require('fs');
async function run() {
try {
// Inputs lesen
const metricName = core.getInput('metric-name');
const value = core.getInput('value');
const color = core.getInput('color');
// Badge generieren (vereinfacht)
const svg = generateBadge(metricName, value, color);
// Datei schreiben
const badgePath = `badges/${metricName}.svg`;
fs.writeFileSync(badgePath, svg);
// Output setzen
core.setOutput('badge-path', badgePath);
// Log
core.info(`Badge generated: ${badgePath}`);
} catch (error) {
core.setFailed(error.message);
}
}
run();Kompilierung:
npm install @vercel/ncc -g
ncc build index.js -o distDas dist/index.js enthält dann alle Dependencies
gebundelt.
Wenn spezifische Tools oder OS-Dependencies nötig sind: Docker Actions.
action.yml:
name: 'Python Linter'
description: 'Run Python linting with all tools'
inputs:
path:
description: 'Path to lint'
required: false
default: '.'
runs:
using: 'docker'
image: 'Dockerfile'
args:
- ${{ inputs.path }}Dockerfile:
FROM python:3.12-slim
RUN pip install ruff mypy black
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]entrypoint.sh:
#!/bin/bash
set -e
PATH_TO_LINT=${1:-.}
echo "Running ruff..."
ruff check "$PATH_TO_LINT"
echo "Running mypy..."
mypy "$PATH_TO_LINT"
echo "Running black..."
black --check "$PATH_TO_LINT"Trade-off: Docker Actions sind langsamer (Image muss gebaut/gepullt werden), aber bieten vollständige Kontrolle über die Umgebung.
| Aspekt | Reusable Workflow | Composite Action |
|---|---|---|
| Granularität | Gesamter Workflow (Jobs) | Einzelne Steps |
| Runner-Auswahl | Ja (pro Job) | Nein (erbt vom Caller) |
| Logging | Separate Jobs sichtbar | Ein Step im Caller |
| Secrets-Zugriff | Explizit durchgereicht | Als Inputs/Env |
| Environment-Vars | Nicht vererbt | Vererbt |
| Use-Case | Komplette Pipelines | Wiederverwendbare Step-Bundles |
Faustregel:
my-org/shared-workflows/
├── .github/
│ └── workflows/
│ ├── ci-python.yml
│ ├── ci-node.yml
│ ├── release.yml
│ └── deploy-pages.yml
├── actions/
│ ├── setup-python-env/
│ │ └── action.yml
│ ├── generate-changelog/
│ │ └── action.yml
│ └── notify-slack/
│ └── action.yml
└── README.md
# .github/workflows/ci-python.yml
name: Python CI
on:
workflow_call:
inputs:
python-version:
type: string
default: '3.12'
os:
type: string
default: 'ubuntu-latest'
run-lint:
type: boolean
default: true
run-security:
type: boolean
default: true
coverage-threshold:
type: number
default: 80
secrets:
codecov-token:
required: false
outputs:
coverage:
description: 'Test coverage percentage'
value: ${{ jobs.test.outputs.coverage }}
jobs:
lint:
if: ${{ inputs.run-lint }}
runs-on: ${{ inputs.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
- name: Install linters
run: pip install ruff mypy
- name: Run ruff
run: ruff check .
- name: Run mypy
run: mypy src/ --ignore-missing-imports
security:
if: ${{ inputs.run-security }}
runs-on: ${{ inputs.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install security tools
run: pip install bandit safety
- name: Run Bandit
run: bandit -r src/
- name: Check dependencies
run: safety check
test:
runs-on: ${{ inputs.os }}
outputs:
coverage: ${{ steps.coverage.outputs.percentage }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
- name: Install dependencies
run: pip install -e .[dev]
- name: Run tests
run: pytest --cov=src --cov-report=xml
- name: Extract coverage
id: coverage
run: |
COVERAGE=$(python -c "import xml.etree.ElementTree as ET; print(int(float(ET.parse('coverage.xml').getroot().get('line-rate')) * 100))")
echo "percentage=$COVERAGE" >> $GITHUB_OUTPUT
- name: Check coverage threshold
run: |
if [[ ${{ steps.coverage.outputs.percentage }} -lt ${{ inputs.coverage-threshold }} ]]; then
echo "Coverage ${{ steps.coverage.outputs.percentage }}% is below threshold ${{ inputs.coverage-threshold }}%"
exit 1
fi
- name: Upload to Codecov
if: ${{ secrets.codecov-token != '' }}
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.codecov-token }}# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
ci:
uses: my-org/shared-workflows/.github/workflows/ci-python.yml@v1
with:
python-version: '3.12'
run-lint: true
run-security: true
coverage-threshold: 85
secrets:
codecov-token: ${{ secrets.CODECOV_TOKEN }}
report:
needs: ci
runs-on: ubuntu-latest
steps:
- run: echo "Coverage: ${{ needs.ci.outputs.coverage }}%"Der gesamte CI-Prozess (Lint + Security + Test) in 10 Zeilen YAML.
Third-Party Actions haben vollen Zugriff auf:
Ein kompromittiertes Action-Repository kann Secrets exfiltrieren, Code manipulieren oder Supply-Chain-Angriffe durchführen.
Reales Beispiel: Im März 2025 wurde
tj-actions/changed-files kompromittiert. Alle Tags wurden
auf einen Commit mit Malware umgebogen.
Tags können geändert werden. Branches sowieso. Der einzige immutable Identifier ist der Commit SHA.
Unsicher:
- uses: actions/checkout@v4 # Tag kann verschoben werden
- uses: actions/checkout@main # Branch ändert sich ständigSicher:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1Methode 1: GitHub UI
github.com/actions/checkout)Methode 2: CLI
git ls-remote --tags https://github.com/actions/checkout.git | grep v4.1.1
# Output: b4ffde65f46336ab88eb53be808477a3936bae11 refs/tags/v4.1.1steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
- uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v4.0.1Warum der Kommentar? Menschen können SHA nicht lesen. Der Kommentar zeigt die Version.
SHA-Pinning bedeutet kein automatisches Update. Dependabot hilft:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"Was passiert:
Beispiel-PR:
ci: bump actions/checkout from v4.1.1 to v4.1.2
Updates actions/checkout from b4ffde65... to a12b4567...
1. Permissions minimieren:
permissions:
contents: read # Nur was nötig ist2. Verified Creator bevorzugen:
Im Marketplace haben Actions von verifizierten Erstellern ein Badge. GitHub hat die Identität überprüft.
3. Action-Code reviewen:
Bei kritischen Actions (die Secrets sehen): Code auf GitHub anschauen. Was macht die Action wirklich?
4. Fork als Absicherung:
Für maximale Kontrolle: Action in eigene Organisation forken und von dort referenzieren.
- uses: my-org/checkout@v4 # Geforktes RepositoryNachteil: Manuelles Sync bei Updates nötig.
pinact – CLI-Tool zum automatischen Pinning:
# Installation
go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest
# Workflow pinnen
pinact runWandelt actions/checkout@v4 automatisch in
actions/checkout@b4ffde65... # v4.1.1 um.
Symptom: secrets.MY_SECRET ist leer im
Called Workflow.
Ursache: Secrets werden nicht automatisch vererbt.
Lösung:
# Explizit
secrets:
MY_SECRET: ${{ secrets.MY_SECRET }}
# Oder alle
secrets: inheritSymptom: uses: in Composite Action
funktioniert nicht.
Ursache: Ältere GitHub Enterprise Versionen unterstützen das nicht.
Lösung: GitHub Enterprise auf Version 3.4+
aktualisieren oder nur run: Steps verwenden.
Symptom: Caller will Matrix an Called Workflow übergeben.
Realität: Nicht direkt möglich. Matrix muss im Called Workflow definiert sein.
Workaround: Matrix als JSON-Input übergeben und mit
fromJSON() parsen:
# Caller
jobs:
ci:
uses: my-org/shared/.github/workflows/ci.yml@main
with:
matrix-json: '{"python": ["3.11", "3.12"], "os": ["ubuntu-latest"]}'
# Called
on:
workflow_call:
inputs:
matrix-json:
type: string
required: true
jobs:
test:
strategy:
matrix: ${{ fromJSON(inputs.matrix-json) }}Symptom: uses: ./my-action im Reusable
Workflow findet Action nicht.
Ursache: Relative Pfade beziehen sich auf das Caller-Repository, nicht das Workflow-Repository.
Lösung: Action separat referenzieren:
uses: my-org/shared-workflows/my-action@mainRegel: Nested Workflows können Permissions nur einschränken, nicht erweitern.
Workflow A (contents: write)
→ calls B (contents: write) ✅
→ calls B (contents: read) ✅
→ calls B (packages: write) ❌ (A hat packages nicht)
Lokale Composite Actions:
my-repo/
├── .github/
│ ├── actions/
│ │ └── setup-env/
│ └── workflows/
│ └── ci.yml
Actions im selben Repository, keine externe Abhängigkeit.
Zentrales shared-workflows Repository:
org/shared-workflows/ # Zentrale Workflows + Actions
org/app-1/ # uses: org/shared-workflows/...
org/app-2/
org/app-3/
Ein Team pflegt die Workflows, alle nutzen sie.
Hierarchische Struktur:
org/platform-workflows/ # Basis-Workflows (Security, Compliance)
org/team-a-workflows/ # Team-spezifische Erweiterungen
org/team-b-workflows/
org/app-1/ # Kombiniert Platform + Team Workflows
Platform-Team definiert Mindeststandards, Teams erweitern.
1. Semantic Versioning:
uses: my-org/shared/.github/workflows/ci.yml@v1.0.02. Major-Version-Tags (empfohlen):
uses: my-org/shared/.github/workflows/ci.yml@v1Tag v1 zeigt immer auf den neuesten v1.x.x
Release. Breaking Changes → v2.
3. Maintenance:
# Nach Release v1.2.0
git tag -f v1 v1.2.0 # v1 auf v1.2.0 verschieben
git push origin v1 --forceWorkflow: