Composite Actions sind der einfachste Einstieg in die
Action-Entwicklung. Sie bestehen aus reinem YAML, erfordern keine
Programmiersprache und können trotzdem mächtige Abstraktionen schaffen.
In diesem Kapitel entwickeln wir Composite Actions von Grund auf – von
der action.yml-Struktur über Input-Handling bis zur
Fehlerbehandlung.
Composite Actions eignen sich für Szenarien, in denen man Step-Sequenzen kapseln möchte, die aus Shell-Befehlen und/oder anderen Actions bestehen. Sie sind das Mittel der Wahl, wenn:
Der entscheidende Unterschied zu JavaScript/Docker Actions: Composite Actions haben keinen eigenen Runtime-Kontext. Sie werden zur Laufzeit “entfaltet” und ihre Steps laufen direkt im Job-Kontext – mit Zugriff auf denselben Workspace, dieselben Environment Variables.
Jede Action – egal welchen Typs – benötigt eine
action.yml (oder action.yaml) im
Root-Verzeichnis. Diese Datei definiert Metadaten, Inputs, Outputs und
die eigentliche Ausführungslogik.
name: 'My Composite Action'
description: 'Eine kurze Beschreibung der Action'
runs:
using: 'composite'
steps:
- run: echo "Hello from Composite Action"
shell: bashDas using: 'composite' ist der Marker, der diese Action
als Composite Action identifiziert. Die steps folgen
derselben Syntax wie Workflow-Steps – mit einem wichtigen Unterschied:
Bei run-Steps muss das shell immer
explizit angegeben werden.
name: 'Setup and Build'
description: 'Installiert Dependencies und baut das Projekt'
author: 'DevOps Team'
branding:
icon: 'package'
color: 'blue'
inputs:
node-version:
description: 'Node.js Version'
required: false
default: '20'
working-directory:
description: 'Arbeitsverzeichnis für npm-Befehle'
required: false
default: '.'
build-command:
description: 'Build-Befehl'
required: false
default: 'npm run build'
outputs:
build-path:
description: 'Pfad zum Build-Output'
value: ${{ steps.build.outputs.path }}
artifact-name:
description: 'Name des erzeugten Artifacts'
value: ${{ steps.meta.outputs.name }}
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- name: Install dependencies
run: npm ci
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Build
id: build
run: |
${{ inputs.build-command }}
echo "path=${{ inputs.working-directory }}/dist" >> $GITHUB_OUTPUT
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Generate metadata
id: meta
run: |
NAME="build-$(date +%Y%m%d-%H%M%S)"
echo "name=$NAME" >> $GITHUB_OUTPUT
shell: bashInputs machen Composite Actions konfigurierbar. Jeder Input hat:
| Eigenschaft | Beschreibung | Pflicht |
|---|---|---|
description |
Dokumentation für Nutzer | Ja |
required |
Muss der Input angegeben werden? | Nein (default: false) |
default |
Standardwert wenn nicht angegeben | Nein |
deprecationMessage |
Warnung für veraltete Inputs | Nein |
inputs:
environment:
description: 'Zielumgebung (dev, staging, prod)'
required: true
timeout:
description: 'Timeout in Sekunden'
required: false
default: '300'
legacy-mode:
description: 'Aktiviert Legacy-Kompatibilität'
deprecationMessage: 'legacy-mode wird in v3 entfernt. Bitte migrieren.'
required: false
default: 'false'Inputs sind über den inputs-Kontext verfügbar:
steps:
- run: |
echo "Environment: ${{ inputs.environment }}"
echo "Timeout: ${{ inputs.timeout }}"
shell: bashWichtig: Der inputs-Kontext ist nur
innerhalb der Composite Action verfügbar. Im aufrufenden Workflow
existiert er nicht.
Composite Actions haben keine eingebaute Validierung. Man muss sie selbst implementieren:
steps:
- name: Validate inputs
run: |
# Environment validieren
case "${{ inputs.environment }}" in
dev|staging|prod)
echo "✓ Environment '${{ inputs.environment }}' ist gültig"
;;
*)
echo "✗ Ungültiges Environment: '${{ inputs.environment }}'"
echo " Erlaubt: dev, staging, prod"
exit 1
;;
esac
# Timeout als Zahl prüfen
if ! [[ "${{ inputs.timeout }}" =~ ^[0-9]+$ ]]; then
echo "✗ Timeout muss eine Zahl sein: '${{ inputs.timeout }}'"
exit 1
fi
shell: bashEin exit 1 in einem Step bricht die Action ab – und
damit auch den aufrufenden Job (sofern nicht
continue-on-error gesetzt ist).
YAML-Booleans werden in Composite Actions als Strings behandelt. Das führt zu Überraschungen:
# Im aufrufenden Workflow
- uses: ./my-action
with:
debug: true # Wird zu String "true"
# In der Action – FALSCH
- if: ${{ inputs.debug }} # Immer true, weil "true" und "false" beide truthy Strings sind!
run: echo "Debug mode"
shell: bash
# In der Action – RICHTIG
- if: ${{ inputs.debug == 'true' }}
run: echo "Debug mode"
shell: bashDie explizite String-Vergleich == 'true' ist der sichere
Weg.
Outputs erlauben es, Daten aus der Composite Action an den aufrufenden Workflow zurückzugeben. Der Mechanismus ist zweistufig:
$GITHUB_OUTPUTaction.yml mappt den Step-Output auf einen
Action-Outputoutputs:
version:
description: 'Ermittelte Version'
value: ${{ steps.version.outputs.value }}
changelog:
description: 'Changelog seit letztem Release'
value: ${{ steps.changelog.outputs.content }}
runs:
using: 'composite'
steps:
- name: Determine version
id: version
run: |
VERSION=$(cat package.json | jq -r '.version')
echo "value=$VERSION" >> $GITHUB_OUTPUT
shell: bash
- name: Generate changelog
id: changelog
run: |
# Multiline-Output mit Delimiter
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "content<<$EOF" >> $GITHUB_OUTPUT
git log --oneline HEAD~10..HEAD >> $GITHUB_OUTPUT
echo "$EOF" >> $GITHUB_OUTPUT
shell: bashFür mehrzeilige Werte verwendet man das Heredoc-Pattern mit zufälligem Delimiter:
- name: Capture multiline output
id: report
run: |
# Zufälliger Delimiter verhindert Injection
DELIMITER=$(openssl rand -hex 16)
echo "report<<$DELIMITER" >> $GITHUB_OUTPUT
cat test-results.txt >> $GITHUB_OUTPUT
echo "$DELIMITER" >> $GITHUB_OUTPUT
shell: bashDer zufällige Delimiter ist eine Sicherheitsmaßnahme: Wenn der Output selbst den Delimiter enthielte, würde er das Output vorzeitig beenden.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build with custom action
id: build
uses: ./actions/setup-and-build
with:
node-version: '20'
- name: Use outputs
run: |
echo "Version: ${{ steps.build.outputs.version }}"
echo "Build path: ${{ steps.build.outputs.build-path }}"In Composite Actions muss für jeden run-Step explizit
ein Shell angegeben werden:
| Shell | Plattform | Befehl |
|---|---|---|
bash |
Linux, macOS, Windows (Git Bash) | bash --noprofile --norc -eo pipefail {0} |
sh |
Linux, macOS | sh -e {0} |
pwsh |
Alle (PowerShell Core) | pwsh -command ". '{0}'" |
powershell |
Windows | powershell -command ". '{0}'" |
cmd |
Windows | cmd /D /E:ON /V:OFF /S /C "CALL "{0}"" |
python |
Alle (wenn installiert) | python {0} |
Für Actions, die auf allen Plattformen laufen sollen, hat man zwei Optionen:
Option 1: Bash überall (einfach)
steps:
- run: |
echo "Works on Linux, macOS, and Windows (Git Bash)"
shell: bashWindows-Runner haben Git Bash vorinstalliert. Für die meisten Fälle reicht das.
Option 2: Plattform-spezifische Steps (robust)
steps:
- name: Setup (Linux/macOS)
if: runner.os != 'Windows'
run: |
chmod +x ./scripts/setup.sh
./scripts/setup.sh
shell: bash
- name: Setup (Windows)
if: runner.os == 'Windows'
run: |
.\scripts\setup.ps1
shell: pwshBash mit -eo pipefail (der Default) bricht bei Fehlern
ab. Das ist meist gewünscht, aber manchmal hinderlich:
steps:
# Befehl darf fehlschlagen
- name: Optional cleanup
run: |
rm -rf ./temp 2>/dev/null || true
shell: bash
# Fehler explizit behandeln
- name: Check with fallback
run: |
if ! command -v docker &> /dev/null; then
echo "::warning::Docker nicht gefunden, überspringe Container-Tests"
echo "docker_available=false" >> $GITHUB_OUTPUT
else
echo "docker_available=true" >> $GITHUB_OUTPUT
fi
shell: bash
id: checkComposite Actions können selbst andere Actions aufrufen – das ist ihre Stärke:
runs:
using: 'composite'
steps:
# Marketplace Action
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# Andere Composite Action aus demselben Repo
- uses: ./.github/actions/lint
# Action aus anderem Repository
- uses: owner/repo/.github/actions/shared@v1
# Shell-Befehl
- run: pip install -r requirements.txt
shell: bashWichtig: Der uses-Pfad in einer
Composite Action ist relativ zum Repository-Root, nicht
zur Action selbst.
Ein häufiges Pattern: Eine existierende Action mit eigenen Defaults wrappen:
name: 'Org Node Setup'
description: 'Setup Node.js mit Organisations-Defaults'
inputs:
node-version:
description: 'Node.js Version'
required: false
default: '20'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
scope: '@myorg'
- name: Configure npm
run: |
npm config set //npm.pkg.github.com/:_authToken ${{ env.NPM_TOKEN }}
npm config set @myorg:registry https://npm.pkg.github.com
shell: bash
env:
NPM_TOKEN: ${{ env.NPM_TOKEN }}Teams verwenden dann
uses: ./.github/actions/org-node-setup statt die
Konfiguration in jedem Workflow zu wiederholen.
Composite Actions laufen im selben Workspace wie der Job. Nach einem
actions/checkout hat die Action Zugriff auf alle
Repository-Dateien:
runs:
using: 'composite'
steps:
- name: Read config
run: |
if [[ -f ".github/config.yml" ]]; then
CONFIG=$(cat .github/config.yml)
echo "Config gefunden"
else
echo "::warning::Keine Config gefunden, verwende Defaults"
fi
shell: bashActions können eigene Dateien mitbringen. Der Pfad zur Action selbst
ist über ${{ github.action_path }} verfügbar:
.github/actions/my-action/
├── action.yml
├── scripts/
│ ├── setup.sh
│ └── validate.py
└── templates/
└── config.template.json
runs:
using: 'composite'
steps:
- name: Run bundled script
run: |
chmod +x "${{ github.action_path }}/scripts/setup.sh"
"${{ github.action_path }}/scripts/setup.sh"
shell: bash
- name: Copy template
run: |
cp "${{ github.action_path }}/templates/config.template.json" ./config.json
shell: bashAchtung: github.action_path zeigt auf
das Verzeichnis, in dem die action.yml liegt – nicht auf
das Repository-Root.
GitHub Actions interpretiert bestimmte Echo-Patterns als Befehle:
steps:
- name: Logging examples
run: |
# Debug-Message (nur sichtbar wenn ACTIONS_STEP_DEBUG=true)
echo "::debug::Detaillierte Debug-Info"
# Normale Logs mit Leveln
echo "::notice::Informative Nachricht"
echo "::warning::Warnung, aber kein Fehler"
echo "::error::Fehler aufgetreten"
# Mit Datei-Annotation
echo "::warning file=src/app.js,line=10,col=5::Deprecated function used"
# Gruppen für bessere Lesbarkeit
echo "::group::Installing dependencies"
npm ci
echo "::endgroup::"
# Secret masken (z.B. dynamisch generierte Werte)
TOKEN=$(generate-token)
echo "::add-mask::$TOKEN"
echo "Token ist jetzt maskiert: $TOKEN" # Zeigt ***
shell: bashFür ausführliches Debugging kann man einen Debug-Input anbieten:
inputs:
debug:
description: 'Aktiviert Debug-Ausgaben'
required: false
default: 'false'
runs:
using: 'composite'
steps:
- name: Debug info
if: ${{ inputs.debug == 'true' }}
run: |
echo "::group::Debug Information"
echo "Runner OS: ${{ runner.os }}"
echo "Workspace: ${{ github.workspace }}"
echo "Action path: ${{ github.action_path }}"
echo "Event: ${{ github.event_name }}"
env
echo "::endgroup::"
shell: bashStatt nur exit 1 sollte man hilfreiche Fehlermeldungen
ausgeben:
steps:
- name: Validate environment
run: |
ERRORS=()
if [[ -z "${{ inputs.api-url }}" ]]; then
ERRORS+=("api-url ist erforderlich")
fi
if [[ ! -f "package.json" ]]; then
ERRORS+=("package.json nicht gefunden – ist actions/checkout gelaufen?")
fi
if [[ ${#ERRORS[@]} -gt 0 ]]; then
echo "::error::Validierung fehlgeschlagen:"
for err in "${ERRORS[@]}"; do
echo "::error:: - $err"
done
exit 1
fi
shell: bashEine vollständige Composite Action für Python-Projekte:
# .github/actions/python-setup/action.yml
name: 'Python Project Setup'
description: 'Richtet eine Python-Umgebung ein mit Caching und optionalem Linting'
author: 'Platform Team'
branding:
icon: 'code'
color: 'yellow'
inputs:
python-version:
description: 'Python-Version'
required: false
default: '3.11'
requirements-file:
description: 'Pfad zur Requirements-Datei'
required: false
default: 'requirements.txt'
install-dev:
description: 'Installiert auch dev-dependencies'
required: false
default: 'false'
run-lint:
description: 'Führt Linting mit ruff aus'
required: false
default: 'false'
working-directory:
description: 'Arbeitsverzeichnis'
required: false
default: '.'
outputs:
python-path:
description: 'Pfad zur Python-Installation'
value: ${{ steps.setup.outputs.python-path }}
cache-hit:
description: 'Ob der Dependency-Cache getroffen wurde'
value: ${{ steps.cache.outputs.cache-hit }}
lint-status:
description: 'Ergebnis des Linting (success/skipped/failed)'
value: ${{ steps.lint-result.outputs.status }}
runs:
using: 'composite'
steps:
- name: Validate inputs
run: |
cd "${{ inputs.working-directory }}"
if [[ ! -f "${{ inputs.requirements-file }}" ]]; then
echo "::error::Requirements-Datei nicht gefunden: ${{ inputs.requirements-file }}"
echo "::error::Arbeitsverzeichnis: $(pwd)"
exit 1
fi
shell: bash
- name: Setup Python
id: setup
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Cache pip dependencies
id: cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ inputs.python-version }}-${{ hashFiles(format('{0}/{1}', inputs.working-directory, inputs.requirements-file)) }}
restore-keys: |
${{ runner.os }}-pip-${{ inputs.python-version }}-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r "${{ inputs.requirements-file }}"
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Install dev dependencies
if: ${{ inputs.install-dev == 'true' }}
run: |
if [[ -f "requirements-dev.txt" ]]; then
pip install -r requirements-dev.txt
elif [[ -f "pyproject.toml" ]]; then
pip install -e ".[dev]"
else
echo "::warning::Keine Dev-Dependencies gefunden"
fi
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Run linting
id: lint
if: ${{ inputs.run-lint == 'true' }}
continue-on-error: true
run: |
if ! command -v ruff &> /dev/null; then
pip install ruff
fi
ruff check .
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Set lint result
id: lint-result
run: |
if [[ "${{ inputs.run-lint }}" != "true" ]]; then
echo "status=skipped" >> $GITHUB_OUTPUT
elif [[ "${{ steps.lint.outcome }}" == "success" ]]; then
echo "status=success" >> $GITHUB_OUTPUT
else
echo "status=failed" >> $GITHUB_OUTPUT
echo "::warning::Linting hat Probleme gefunden"
fi
shell: bash
- name: Summary
run: |
echo "### Python Setup Complete ✓" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Setting | Value |" >> $GITHUB_STEP_SUMMARY
echo "|---------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Python | ${{ inputs.python-version }} |" >> $GITHUB_STEP_SUMMARY
echo "| Cache Hit | ${{ steps.cache.outputs.cache-hit || 'false' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Lint Status | ${{ steps.lint-result.outputs.status }} |" >> $GITHUB_STEP_SUMMARY
shell: bashVerwendung im Workflow:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python environment
id: python
uses: ./.github/actions/python-setup
with:
python-version: '3.12'
install-dev: 'true'
run-lint: 'true'
- name: Check lint status
if: steps.python.outputs.lint-status == 'failed'
run: echo "::warning::Bitte Lint-Fehler beheben"
- name: Run tests
run: pytestmy-repo/
├── .github/
│ ├── actions/
│ │ ├── setup/
│ │ │ └── action.yml
│ │ └── deploy/
│ │ └── action.yml
│ └── workflows/
│ └── ci.yml
└── src/
Aufruf mit relativem Pfad:
steps:
- uses: ./.github/actions/setupVorteile: Versionierung mit dem Code, einfaches Refactoring, keine externe Dependency.
Für organisationsweite Wiederverwendung legt man Actions in ein dediziertes Repository:
org/shared-actions/
├── setup-node/
│ └── action.yml
├── deploy-aws/
│ └── action.yml
└── notify-slack/
└── action.yml
Aufruf mit vollem Pfad:
steps:
- uses: org/shared-actions/setup-node@v1
- uses: org/shared-actions/deploy-aws@v1Wichtig: Bei Shared Actions muss eine Version (Tag, Branch, SHA) angegeben werden.
Für Shared Actions empfiehlt sich Semantic Versioning mit Major-Tags:
# Nach Änderungen
git tag v1.2.3
git push origin v1.2.3
# Major-Tag aktualisieren (für uses: org/action@v1)
git tag -fa v1 -m "Update v1 to v1.2.3"
git push origin v1 --forceNutzer können dann wählen: - @v1 – immer neuestes v1.x.x
(bequem, minor Updates automatisch) - @v1.2.3 – exakte
Version (stabil, keine Überraschungen) - @main – bleeding
edge (nur für Entwicklung) - @abc1234 – SHA-Pinning
(maximale Reproduzierbarkeit)
Composite Actions haben architekturbedingte Limitierungen:
| Feature | Composite Action | JavaScript/Docker Action |
|---|---|---|
| Shell-Befehle | ✓ | ✓ |
| Andere Actions aufrufen | ✓ | ✗ |
| Services (Container) | ✗ | ✗ |
| Eigene Matrix | ✗ | ✗ |
runs-on definieren |
✗ | ✗ |
secrets als Input |
✗ (nur via env) | ✗ |
| Post-Job Cleanup | ✗ | ✓ (post step) |
| Komplexe Logik | Bash/Shell | Programmiersprache |
Wenn man Services, Matrix oder Post-Cleanup braucht, ist ein Reusable Workflow die bessere Wahl. Für komplexe Logik, API-Interaktionen oder Zustandsmanagement sind JavaScript Actions geeigneter.
Composite Actions können keine Secrets als Inputs empfangen. Der Workaround: Environment Variables.
# Im Workflow
- uses: ./.github/actions/deploy
env:
API_KEY: ${{ secrets.API_KEY }}
# In der Action
runs:
using: 'composite'
steps:
- name: Deploy
run: |
curl -H "Authorization: Bearer $API_KEY" https://api.example.com/deploy
shell: bashDie Environment Variable API_KEY ist im Step verfügbar,
ohne dass sie als Input definiert sein muss.
Das Tool act führt GitHub Actions lokal aus:
# Installation (macOS)
brew install act
# Workflow lokal ausführen
act -j test
# Mit spezifischem Event
act push
# Secrets übergeben
act -s API_KEY=test123Einschränkung: act simuliert nicht alle
GitHub-Features perfekt. Es eignet sich für Smoke Tests, ersetzt aber
keine echten Workflow-Runs.
Ein dedizierter Test-Workflow validiert die Action bei Änderungen:
# .github/workflows/test-action.yml
name: Test Composite Action
on:
push:
paths:
- '.github/actions/python-setup/**'
pull_request:
paths:
- '.github/actions/python-setup/**'
jobs:
test-defaults:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/python-setup
- run: python --version
test-custom-version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/python-setup
with:
python-version: '3.10'
- run: |
VERSION=$(python --version)
[[ "$VERSION" == *"3.10"* ]] || exit 1
test-with-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/python-setup
id: setup
with:
run-lint: 'true'
- run: |
echo "Lint status: ${{ steps.setup.outputs.lint-status }}"
test-matrix:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ['3.10', '3.11', '3.12']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/python-setup
with:
python-version: ${{ matrix.python }}
- run: python --versionBevor eine Composite Action in der Organisation verbreitet wird:
Dokumentation - [ ] Aussagekräftige
description in action.yml - [ ] Alle Inputs dokumentiert
mit sinnvollen Defaults - [ ] README.md mit Beispielen (für Shared
Actions) - [ ] Changelog bei Versionierung
Robustheit - [ ] Input-Validierung mit hilfreichen
Fehlermeldungen - [ ] Sinnvolle Defaults für optionale Inputs - [ ]
shell: bash bei allen run-Steps - [ ]
Cross-Platform getestet (wenn relevant)
Observability - [ ] echo "::group::"
für lange Outputs - [ ] Warnings statt Errors bei nicht-kritischen
Problemen - [ ] Step Summary für wichtige Ergebnisse - [ ] Debug-Output
bei debug: 'true'
Wartbarkeit - [ ] Test-Workflow vorhanden - [ ] Semantic Versioning (für Shared Actions) - [ ] Keine hardcodierten Pfade oder Werte - [ ] Abhängigkeiten (andere Actions) mit Version gepinnt