Ein Workflow, der funktioniert, ist gut. Ein Workflow, der schnell funktioniert, ist besser. Ein Workflow, der sich debuggen lässt, wenn er nicht funktioniert, ist Gold wert. Dieses Kapitel zeigt, wie man Workflows performant macht und wie man Probleme systematisch einkreist.
Wir decken Caching-Strategien (von Dependency-Caching bis Docker-Layer-Caching), lokales Testing mit act (Workflows ohne Push testen) und Debug-Logging (wenn Workflows mysteriös fehlschlagen). Am Ende haben wir einen optimierten badge-gen-Workflow mit sub-Minute Build-Zeiten und reproduzierbarem lokalem Testing.
Jeder Workflow-Run ist eine frische VM. Das
bedeutet: Jedes Mal pip install, npm install,
cargo build von Grund auf. Bei kleinen Projekten:
erträglich. Bei großen: Minuten verschwendet.
Caching speichert Dateien zwischen Runs. Dependencies müssen nur einmal heruntergeladen werden, Build-Artefakte können wiederverwendet werden.
GitHub bietet einen Cache-Service, der Dateien zwischen Workflow-Runs speichert. Der Cache ist:
Kernkonzept: Ein Cache-Key identifiziert eindeutig einen Cache-Eintrag. Wenn der Key matched → Cache Hit → Dateien werden restored. Wenn nicht → Cache Miss → Dateien werden neu erstellt und gecacht.
Die offizielle actions/cache Action ist das
Fundament:
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-Parameter:
path: Welche Dateien/Ordner gecacht werden sollenkey: Eindeutiger Cache-Key (wenn Match → Cache
Hit)restore-keys: Fallback-Keys für partielle Matches
(optional)Der Cache-Key ist entscheidend. Er muss:
Typisches Pattern:
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}Aufschlüsselung:
${{ runner.os }} → Linux,
macOS, Windows (OS-spezifisch)pip → Namespace (verhindert Kollision mit
npm-Cache)${{ hashFiles('requirements.txt') }} → SHA-256 Hash der
LockfileBeispiel:
requirements.txt enthält requests==2.28.0
→ Hash: abc123...Linux-pip-abc123...requests==2.31.0 → Hash: def456... →
neuer Key → Cache Miss → neuer Cache wird erstellthashFiles() ist die zentrale Funktion für
Cache-Keys:
# Single file
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
# Multiple files (OR-Pattern)
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json', 'yarn.lock') }}
# Glob patterns
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}Wichtig: hashFiles() gibt
leeren String zurück, wenn keine Dateien matchen. Das
kann zu unbeabsichtigten Cache-Hits führen!
Best Practice: Immer mindestens eine existierende Datei im Pattern.
Was, wenn requirements.txt sich ändert, aber 90% der
Dependencies gleich geblieben sind?
Lösung: restore-keys ermöglicht
partielle Matches:
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-Ablauf:
Linux-pip-abc123Linux-pip-
beginnen (neuester wird genommen)Linux-
beginnenDas bedeutet: Selbst bei geänderten Dependencies wird der alte Cache als Basis genutzt, nur die Diffs werden neu geladen.
| Limit | Wert |
|---|---|
| Max. Cache-Größe pro Repository | 10 GB |
| Max. einzelner Cache-Eintrag | Keine harte Grenze (praktisch: mehrere GB möglich) |
| Retention | 7 Tage ohne Zugriff → automatische Löschung |
| Max. Caches | Unbegrenzt (bis Gesamt-Limit erreicht) |
Eviction-Policy:
Least Recently Used (LRU). Wenn 10 GB voll sind, werden die ältesten Caches gelöscht.
Best Practice:
Nicht jeden Build-Output cachen. Nur teure Operationen: - ✅
Dependencies (node_modules, .cache/pip,
~/.cargo) - ✅ Kompilierte Artefakte (für incremental
builds) - ❌ Source-Code (wird per checkout geholt) - ❌
Test-Results (klein, schnell generiert)
Die offiziellen Setup-Actions haben built-in
Caching. Das ist meist eleganter als manuelles
actions/cache.
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'Was passiert:
setup-python installiert Python 3.12~/.cache/pip
basierend auf requirements.txt HashWichtig: Funktioniert nur, wenn
requirements.txt oder pyproject.toml im Root
liegen.
Vergleich:
| Manuell mit actions/cache | Built-in cache |
|---|---|
Expliziter path nötig |
Automatisch korrekt |
Manueller key |
Automatisch aus Lockfile |
| ~10 Zeilen YAML | 1 Zeile |
Use-Case für manuelles Caching: Custom Cache-Pfade
(z.B. ./vendor/ statt ~/.cache/pip).
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # oder 'yarn', 'pnpm'Cached:
~/.npm~/.yarn/cache + .pnp.cjs (wenn PnP
aktiviert)~/.pnpm-storeLockfile-Detection: Automatisch
package-lock.json, yarn.lock,
pnpm-lock.yaml.
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle' # oder 'maven'Cached:
~/.m2/repository~/.gradle/caches,
~/.gradle/wrapperjobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -e .[dev]
- name: Run tests
run: pytestLaufzeit: ~2 Minuten (1:30 für pip install)
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -e .[dev]
- name: Run tests
run: pytestLaufzeit:
Einsparung: 75% Laufzeit
Caches sind OS-spezifisch. Das ist gut so (Linux != Windows Binaries).
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.11', '3.12']
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'Cache-Keys generiert:
Linux-pip-abc123-3.11Linux-pip-abc123-3.12Windows-pip-abc123-3.11Windows-pip-abc123-3.12macOS-pip-abc123-3.11macOS-pip-abc123-3.12Jeder Matrix-Job hat seinen eigenen Cache. Das ist korrekt: macOS-Wheels ≠ Linux-Wheels.
Docker-Builds sind notorisch langsam. Layer-Caching ist der Gamechanger.
- name: Build Docker image
run: docker build -t myapp:latest .Jeder Run: Alle Layers neu bauen. Auch wenn sich nur
app.py geändert hat, wird RUN pip install
komplett neu ausgeführt.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=maxWas passiert:
cache-from: type=gha → Liest Layers aus GitHub Actions
Cachecache-to: type=gha,mode=max → Speichert
alle Layers (mode=max)Dockerfile-Optimierung für Caching:
# ❌ Schlecht: Cache-Bust bei jeder Code-Änderung
COPY . /app
RUN pip install -r requirements.txt
# ✅ Gut: Dependencies zuerst (ändern sich selten)
COPY requirements.txt /app/
RUN pip install -r requirements.txt
COPY . /appPrinzip: Seltene Änderungen oben, häufige Änderungen unten im Dockerfile.
Problem: Workflow schreiben → Push → Warten → Fehler → Korrektur → Push → Warten → …
Lösung: Workflows lokal testen mit act.
act ist ein CLI-Tool (Open Source,
nektos/act), das Workflows auf dem lokalen
Rechner in Docker-Containern ausführt. Es simuliert die GitHub
Actions Umgebung.
macOS (Homebrew):
brew install actLinux:
curl -sSf https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bashWindows (Chocolatey):
choco install act-cli# Workflow mit push Event triggern
act push
# Spezifisches Event
act pull_request
# Spezifischer Job
act -j test
# Alle Jobs auflisten
act -lBeim ersten Run fragt act nach einem Image:
| Image | Größe | Beschreibung |
|---|---|---|
| Micro | ~200 MB | Minimal (nur Node.js) |
| Medium | ~500 MB | Ubuntu mit basics |
| Large | ~18 GB | Exakte GitHub-hosted Runner Kopie |
Empfehlung für Entwicklung: Medium (guter Kompromiss).
In .actrc dauerhaft setzen:
-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
Secrets-Datei (.secrets):
API_KEY=test-key-12345
AWS_ACCESS_KEY_ID=local-testing
Variables-Datei (.vars):
API_URL=http://localhost:8080
Verwendung:
act --secret-file .secrets --var-file .varsWichtig: .secrets und
.vars in .gitignore aufnehmen!
act ist nicht identisch mit
GitHub-hosted Runners:
| Feature | GitHub | act |
|---|---|---|
| Linux-Jobs | ✅ | ✅ |
| macOS-Jobs | ✅ | ⚠️ Nur auf macOS-Host |
| Windows-Jobs | ✅ | ⚠️ Nur auf Windows-Host |
| workflow_dispatch Inputs | ✅ | ⚠️ Limitiert |
| GITHUB_TOKEN | ✅ | ❌ Lokal generierter Dummy |
| Hosted Secrets | ✅ | ❌ Lokale Dateien |
Best Practice: act für schnelle
Iterationen, finale Validierung immer auf GitHub.
# .github/workflows/test.yml
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e .[dev]
- run: pytestLokal ausführen:
# Einfacher Test
act push
# Mit Debug-Output
act push -v
# Nur test-Job
act -j test
# Mit Secrets
echo "CODECOV_TOKEN=test" > .secrets
act push --secret-file .secretsWorkflow:
act testen (Feedback in Sekunden)Zeit-Ersparnis: Entwicklungszeit von Stunden auf Minuten reduziert.
Manchmal schlagen Workflows fehl, und die Logs sagen nichts. Dann braucht man Debug-Logging.
Aktiviert verbose Output für jeden Step.
Aktivierung:
Repository Variable/Secret setzen:
ACTIONS_STEP_DEBUG = true
Settings → Secrets and variables → Actions → Variables → New repository variable
Effekt: Logs zeigen jetzt:
##[debug]Evaluating condition for step: 'Run tests'
##[debug]Evaluating: success()
##[debug]=> true
##[debug]Result: true
##[debug]Starting: Run tests
##[debug]Loading env
...
Informationen im Debug-Log:
if Statements)Aktiviert Runner-Prozess-Logs.
Aktivierung:
Repository Variable/Secret setzen:
ACTIONS_RUNNER_DEBUG = true
Effekt: Zwei zusätzliche Log-Dateien im Download:
Runner_xxx.log – Runner-Koordination (Job-Setup,
Image-Pull, etc.)Worker_xxx.log – Job-Ausführung (Step-Execution,
Exit-Codes)Use-Case: Infrastruktur-Probleme (Runner startet nicht, Network-Issues, Docker-Probleme).
Alternativ zu dauerhaftem Debug-Logging: Re-run einzelner Jobs mit Debug aktiviert.
UI:
Failed Workflow → Re-run jobs → ☑️ Enable debug logging → Re-run failed
jobs
Vorteil: Kein dauerhaftes Secret/Variable setzen, Debug nur für diesen Re-run.
1. Reproduzieren
Kann der Fehler lokal mit act reproduziert werden? Wenn ja
→ schnellere Iteration.
2. Logs analysieren
Standard-Logs durchlesen. Oft steht die Antwort im Exit-Code oder
Error-Message.
3. Debug-Logging aktivieren
Wenn Standard-Logs nicht reichen:
ACTIONS_STEP_DEBUG=true.
4. Step isolieren
Schlägt ein Multi-Command-Step fehl, Commands einzeln ausführen:
# ❌ Unübersichtlich bei Fehler
- run: |
npm install
npm test
npm run build
# ✅ Klare Fehlerquelle
- run: npm install
- run: npm test
- run: npm run build5. Context-Dumping
Unbekannte Werte in Contexts? Dumpen:
- name: Debug Context
run: |
echo "GitHub Context:"
echo '${{ toJSON(github) }}'
echo "Env Context:"
echo '${{ toJSON(env) }}'
echo "Matrix Context:"
echo '${{ toJSON(matrix) }}'6. Tmate-Debugging (Advanced)
Live-SSH in Runner für interaktives Debugging:
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3Workflow pausiert, gibt SSH-Command für Zugriff auf Runner. Achtung: Secrets sind im Runner sichtbar!
Kombinieren wir alles: Caching, Matrix, Debugging.
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
pip install ruff mypy
- name: Run linters
run: |
ruff check .
mypy src/
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -e .[dev]
- name: Run tests
run: pytest -v --cov=src tests/
- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Build package
run: python -m build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/Optimierungen:
lint und
test laufen gleichzeitigcache: 'pip'build wartet auf
lint + test (kein Build bei Fehler)Performance:
✅ Cachen:
pip, npm,
cargo)❌ Nicht cachen:
checkout)# ❌ Sequenziell
jobs:
lint:
steps: [...]
test:
needs: lint
steps: [...]
# ✅ Parallel
jobs:
lint:
steps: [...]
test:
steps: [...]
build:
needs: [lint, test]
steps: [...]lint und test laufen parallel,
build wartet auf beide.
Vermeide parallele Runs für denselben Branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueBei neuem Push auf denselben Branch: laufende Jobs werden gecancelt.
- name: Deploy docs
if: github.ref == 'refs/heads/main'
run: ./deploy-docs.shDeployment nur auf main, nicht auf PRs → spart Zeit.
# ❌ Overkill
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
os: [ubuntu, windows, macos]
# ✅ Fokussiert
matrix:
python-version: ['3.11', '3.12']
os: [ubuntu-latest]
include:
- python-version: '3.12'
os: windows-latestTests auf allen Python-Versionen + Ubuntu, aber nur neueste auf Windows.
Symptom: Cache Hit, aber Restore dauert 5+ Minuten.
Ursache: Cache zu groß (mehrere GB).
Lösung: Granulareres Caching:
# ❌ Alles in einen Cache
path: |
~/.cache
~/.cargo
node_modules
# ✅ Separate Caches
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt') }}
- uses: actions/cache@v4
with:
path: ~/.cargo
key: cargo-${{ hashFiles('Cargo.lock') }}Kleine Caches restoren schneller.
Symptom: requirements.txt geändert,
aber alte Dependencies werden genutzt.
Ursache: Cache-Key hat hashFiles()
nicht korrekt gesetzt.
Debugging:
- name: Debug cache key
run: |
echo "Key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}"Wenn Hash leer → Datei nicht gefunden oder Pattern falsch.
Ursache: Unterschiedliche Runner-Images.
Lösung: GitHub-Image in act nutzen (Large):
act -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:full-latestAchtung: 18 GB Download!
Symptom: In Debug-Logs erscheinen Secrets als Plain-Text.
Ursache: GitHub maskiert Secrets nur in Standard-Output, nicht in transformierten Werten.
Lösung: ::add-mask:: explizit setzen
(siehe Kapitel 3).