Workflows ohne Daten sind wie Programme ohne Variablen – technisch möglich, praktisch nutzlos. Jeder Workflow benötigt Informationen: Wohin deployen? Welchen Branch bauen? Welche Credentials verwenden? GitHub Actions bietet ein durchdachtes, mehrschichtiges System für den Umgang mit Daten. Es unterscheidet zwischen öffentlichen und vertraulichen Informationen, zwischen statischer Konfiguration und dynamischen Laufzeitwerten, zwischen workflow-lokalem und repository-übergreifendem Scope.
Die Herausforderung besteht nicht darin, Daten verfügbar zu machen – GitHub stellt dutzende Variablen automatisch bereit. Die Kunst liegt darin, die richtigen Daten am richtigen Ort auf die richtige Weise zu nutzen. Ein Deployment-Workflow, der hardcodierte Server-URLs enthält, ist wartungsfeindlich. Ein CI-Build, der Secrets in Logs ausgibt, ist ein Sicherheitsrisiko. Ein Job, der nicht auf den aktuellen Branch zugreift, arbeitet womöglich mit veralteten Daten.
Dieses Kapitel behandelt die Mechanismen, mit denen Workflows Informationen handhaben: Umgebungsvariablen für Konfiguration, Kontexte für Laufzeitdaten, Ausdrücke für dynamische Berechnungen und Secrets für sensible Credentials. Wir betrachten nicht nur die Syntax, sondern auch die Designentscheidungen dahinter – wann nutze ich welchen Mechanismus und warum?
GitHub Actions unterscheidet drei Kategorien von Variablen, die sich in Scope, Lebensdauer und Verwendungszweck unterscheiden. Diese Unterscheidung ist nicht akademisch – sie bestimmt, wie wartbar und sicher unsere Workflows werden.
Default Environment Variables setzt GitHub
automatisch bei jedem Workflow-Lauf. Sie existieren als
Umgebungsvariablen auf dem Runner und tragen Namen wie
GITHUB_SHA, GITHUB_REF oder
RUNNER_OS. Diese Variablen beschreiben den Kontext des
aktuellen Laufs: Welcher Commit hat ihn ausgelöst? Auf welchem
Betriebssystem läuft er? Welcher Actor hat ihn gestartet? Sie sind
read-only und konsistent über alle Workflows hinweg – ein verlässliches
Fundament.
Environment Variables definieren wir selbst in der
Workflow-Datei mit dem env:-Schlüssel. Sie können auf
Workflow-, Job- oder Step-Ebene gesetzt werden und folgen einer klaren
Scoping-Regel: je spezifischer, desto vorrangig. Eine Variable auf
Step-Ebene überschreibt die gleichnamige Variable auf Job-Ebene.
Umgebungsvariablen eignen sich für workflow-interne Konfiguration –
Build-Flags, temporäre Pfade, berechnete Werte:
env:
NODE_ENV: production
BUILD_DIR: ./dist
jobs:
build:
env:
ARTIFACT_NAME: app-${{ github.sha }}
steps:
- run: echo "Building in $NODE_ENV mode"
- run: npm run build
env:
VERBOSE: trueConfiguration Variables (verfügbar über den
vars-Kontext) sind das Enterprise-Feature für größere
Setups. Sie werden auf Repository-, Organisations- oder Umgebungsebene
in GitHubs UI definiert und sind über mehrere Workflows hinweg nutzbar.
Das macht sie ideal für Daten, die sich selten ändern, aber an vielen
Stellen gebraucht werden: API-Endpoints, Feature-Flags,
Deployment-Targets. Anders als Umgebungsvariablen in der YAML-Datei
können sie ohne Code-Änderung aktualisiert werden – ein Ops-Team kann
Production-URLs anpassen, ohne Workflows zu editieren.
| Variablenart | Definition | Scope | Typischer Einsatz | Änderbarkeit |
|---|---|---|---|---|
| Default Variables | Automatisch von GitHub | Pro Workflow-Run | Metadaten, Kontext | Read-only |
| Environment Variables | In YAML (env:) |
Workflow/Job/Step | Build-Config, Flags | Per Code-Change |
| Configuration Variables | In GitHub UI | Repo/Org/Environment | URLs, Features, Ziele | Ohne Code-Change |
Während Umgebungsvariablen zur Laufzeit auf dem Runner existieren,
sind Kontexte GitHubs Antwort auf die Frage: Wie greife ich vor
der Runner-Ausführung auf Daten zu? Ein if-Statement, das
einen Job überspringt, wird ausgewertet, bevor der Runner überhaupt
gestartet ist. Ein runs-on, das sich dynamisch entscheidet,
braucht Informationen, bevor die Maschine allokiert wird. Kontexte sind
das Werkzeug für diese Szenarien.
Ein Kontext ist ein JSON-artiges Objekt, das über die Syntax
${{ context.property }} zugänglich wird. GitHub bietet
verschiedene Kontexte für verschiedene Informationstypen. Das Timing
ihrer Verfügbarkeit ist entscheidend.
Der github-Kontext ist die umfassendste Datenquelle. Er
enthält alles, was GitHub über den aktuellen Workflow-Lauf weiß: von
welchem Event er ausgelöst wurde (github.event_name),
welcher Commit involviert ist (github.sha), auf welchem
Branch wir operieren (github.ref_name), wer ihn gestartet
hat (github.actor). Ein Blick in die Struktur zeigt die
Breite:
- name: Debug workflow context
run: |
echo "Event: ${{ github.event_name }}"
echo "Branch: ${{ github.ref_name }}"
echo "Actor: ${{ github.actor }}"
echo "SHA: ${{ github.sha }}"Besonders wertvoll ist github.event – ein Objekt, das
die komplette Webhook-Payload des auslösenden Events enthält. Bei einem
pull_request-Trigger enthält es Details über PR-Nummer,
Titel, Labels, und den kompletten Diff-Kontext. Bei push
sind es Commit-Messages und geänderte Dateien. Diese Granularität
erlaubt hochspezifische Workflow-Logik: “Deploye nur, wenn das Label
‘deploy-ready’ gesetzt ist” oder “Sende Slack-Notification mit der
Commit-Message des Pushes”.
Ein praktischer Hinweis: github.ref
vs. github.ref_name. Ersteres liefert den
vollqualifizierten Ref (refs/heads/main), letzteres nur den
Namen (main). Für die meisten Zwecke – etwa um Branch-Namen
in Artefakt-Pfade einzubauen – ist ref_name das, was wir
wollen.
Der env-Kontext macht unsere selbst definierten
Umgebungsvariablen in Ausdrücken zugänglich. Der Unterschied zur
direkten Shell-Variable ist subtil, aber wichtig:
${{ env.MY_VAR }} wird von GitHub vor der Übergabe
an den Runner ausgewertet, $MY_VAR wird im
Shell-Kontext des Runners ausgewertet.
env:
DEPLOY_ENV: staging
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
if: ${{ env.DEPLOY_ENV == 'production' }}
run: ./deploy.shDie if-Bedingung wird verarbeitet, bevor der Step
überhaupt zum Runner geschickt wird. Das spart Ressourcen –
übersprungene Steps belegen keine Runner-Zeit.
Der env-Kontext folgt dem Scoping: In einem Step sieht man workflow-weite, job-weite und step-weite Variablen. Die innerste Definition gewinnt. Das erlaubt elegante Overrides für Spezialfälle.
Sobald ein Job auf einem Runner startet, wird der
job-Kontext aktiv. Er enthält Laufzeitdetails wie die
Container-ID (falls der Job in einem Container läuft),
Service-Container-Ports (für Datenbanken oder Redis) und den Job-Status.
Besonders nützlich bei Service-Containern:
services:
postgres:
image: postgres:14
ports:
- 5432
steps:
- name: Run tests
run: |
npm test -- --db-port=${{ job.services.postgres.ports[5432] }}GitHub mappt Port 5432 des Containers auf einen zufälligen Host-Port.
Der job-Kontext verrät uns, welcher. Ohne diese Information
könnten Tests nicht auf die Datenbank zugreifen.
Während Jobs isoliert sind und nicht direkt Daten austauschen, können
Steps innerhalb eines Jobs über den steps-Kontext
kommunizieren. Ein Step kann Outputs definieren, die nachfolgende Steps
lesen:
steps:
- name: Generate version
id: versioning
run: echo "version=1.2.${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Build with version
run: |
echo "Building version ${{ steps.versioning.outputs.version }}"
docker build -t app:${{ steps.versioning.outputs.version }} .Die Mechanik: Ein Step schreibt key=value-Paare in die
Datei $GITHUB_OUTPUT. GitHub parsed diese Datei und macht
die Werte im steps.<id>.outputs-Kontext verfügbar.
Wichtig ist das id:-Attribut – ohne ID ist der Step nicht
referenzierbar.
Der steps-Kontext enthält auch outcome und
conclusion – den Erfolg oder Misserfolg vorheriger Steps.
Der Unterschied: outcome ist das unmittelbare Ergebnis,
conclusion berücksichtigt continue-on-error.
Wenn ein Step fehlschlägt, aber continue-on-error: true
hat, ist sein outcome “failure”, aber seine
conclusion “success”.
Jobs sind isoliert, aber nicht inkommunikativ. Über den
needs-Kontext kann ein Job die Outputs abhängiger Jobs
lesen:
jobs:
build:
outputs:
artifact_url: ${{ steps.upload.outputs.url }}
steps:
- name: Upload
id: upload
run: echo "url=https://artifacts.example.com/build-${{ github.sha }}" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact
run: curl -O ${{ needs.build.outputs.artifact_url }}Diese Pattern ist fundamental für Pipeline-Architekturen: Build
erstellt ein Artefakt, Test prüft es, Deploy veröffentlicht es. Ohne
needs und Outputs müssten wir Artefakte über externe
Speicher koordinieren – umständlich und fehleranfällig.
Die ${{ }}-Syntax ist mehr als nur
Variablen-Interpolation. Sie definiert einen eigenen Auswertungsraum mit
Literalen, Operatoren, Funktionen und Kontexten. Ausdrücke machen
Workflows programmatisch.
steps:
- name: Conditional step
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
run: echo "Main branch push detected"Die if-Bedingung kombiniert zwei Kontextwerte mit
logischen Operatoren. GitHub wertet den Ausdruck zu einem Boolean aus.
Nur wenn true, wird der Step ausgeführt.
Ein Sonderfall: In if-Klauseln können die
${{ }}-Wrapper weggelassen werden – GitHub erkennt den
Kontext. if: github.ref == 'refs/heads/main' ist identisch
zur expliziten Form. Das ist Syntax-Zucker, aber bei langen Bedingungen
hilfreich.
Ausdrücke unterstützen die üblichen Verdächtigen:
Vergleichsoperatoren (==, !=,
<, >), logische Operatoren
(&&, ||, !), und
arithmetische Operatoren (+, -,
*, /). Dazu kommen spezielle Funktionen:
| Funktion | Zweck | Beispiel |
|---|---|---|
contains() |
Substring/Array-Check | contains(github.ref, 'feature') |
startsWith() |
Präfix-Check | startsWith(github.ref, 'refs/tags/') |
endsWith() |
Suffix-Check | endsWith(matrix.os, 'latest') |
format() |
String-Formatierung | format('v{0}.{1}', major, minor) |
join() |
Array zu String | join(matrix.node, ', ') |
toJSON() |
Kontext zu JSON | toJSON(github) |
fromJSON() |
JSON zu Objekt | fromJSON(steps.meta.outputs.json) |
hashFiles() |
Datei-Hash für Caching | hashFiles('**/package-lock.json') |
Die contains()-Funktion ist ein Arbeitspferd für Branch-
und Label-Checks. startsWith() und endsWith()
eignen sich für Präfix/Suffix-Logik, etwa “führe aus für alle Tags, die
mit ‘release-’ beginnen”.
Besonders mächtig ist fromJSON(): Es erlaubt, dynamische
Konfiguration aus JSON-Strings zu bauen. Ein Step kann eine
Matrix-Konfiguration berechnen und als JSON-Output ausgeben, ein anderer
Job konsumiert sie via fromJSON() als Matrix-Strategie. Das
ermöglicht Meta-Programmierung auf Workflow-Ebene.
Vier spezielle Funktionen prüfen den Workflow-Status:
steps:
- name: Always run
if: always()
run: echo "I run even if previous steps failed"
- name: On success
if: success()
run: echo "All previous steps succeeded"
- name: On failure
if: failure()
run: echo "A previous step failed"
- name: If cancelled
if: cancelled()
run: echo "Workflow was cancelled"Diese Funktionen verändern das Default-Verhalten: Normalerweise
überspringt GitHub nachfolgende Steps, wenn ein Step fehlschlägt. Mit
if: always() erzwingen wir Ausführung – nützlich für
Cleanup-Tasks oder Benachrichtigungen. Mit if: failure()
können wir auf Fehler reagieren, etwa Logs hochladen oder Alerts
senden.
Credentials, API-Keys, Tokens – sensible Daten gehören nicht in
Workflow-Dateien. GitHub bietet Secrets als verschlüsselte
Speichermechanismus auf Repository-, Organisations- und Umgebungsebene.
Secrets sind write-only in der UI: Man kann sie setzen, aber nicht
auslesen. Im Workflow sind sie über den secrets-Kontext
verfügbar.
steps:
- name: Deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
aws s3 sync ./dist s3://my-bucketGitHub redacted Secrets automatisch in Logs. Wenn ein Secret-Wert in
einem Log auftaucht – ob durch echo,
console.log oder Fehler-Output – ersetzt GitHub ihn durch
***. Das ist ein Sicherheitsnetz, aber kein
Freifahrtschein: Strukturierte Daten (JSON mit Secrets) oder
Base64-kodierte Secrets können durch das Raster fallen. Best Practice:
Secrets explizit nur dort übergeben, wo sie gebraucht werden, nicht
breit streuen.
Secrets folgen demselben Precedence-Modell wie Konfigurationsvariablen: Umgebungs-Secrets überschreiben Repository-Secrets, diese wiederum Organisations-Secrets. Das erlaubt Default-Credentials auf Org-Ebene mit Overrides für spezielle Repos oder Environments.
Ein wichtiger Unterschied zu Variablen: Environment-Level Secrets
werden erst gelesen, wenn der Job startet, nicht bei
Workflow-Queue-Zeit. Das hat Konsequenzen für
if-Bedingungen auf Job-Ebene – sie können keine
Environment-Secrets referenzieren, da diese noch nicht verfügbar
sind.
jobs:
deploy:
environment: production
steps:
- name: Use production secret
run: echo "Deploying with ${{ secrets.PROD_API_KEY }}"
# Das funktioniert - Job läuft bereits
check:
if: ${{ secrets.PROD_API_KEY != '' }} # Das funktioniert NICHT
# Job-Level if kann keine Environment-Secrets sehenJeder Workflow bekommt automatisch ein
GITHUB_TOKEN-Secret. Dieses Token authentifiziert den
Workflow gegenüber der GitHub API und erlaubt Operationen wie Kommentare
auf Issues, Releases erstellen, Packages publishen. Die Permissions sind
standardmäßig liberal, können aber per
permissions:-Schlüssel eingeschränkt werden:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- name: Comment on PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ github.event.number }} --body "Build completed!"Das Token ist kurzlebig (Lebensdauer des Workflow-Runs) und scope-limitiert (nur auf das auslösende Repository, es sei denn, man konfiguriert anders). Für Operationen außerhalb des Repos – etwa Cross-Repo-Triggers oder Package-Registry-Zugriffe in anderen Orgs – braucht es PATs (Personal Access Tokens), die als reguläre Secrets hinterlegt werden.
GitHub erzwingt Regeln für Variablen- und Secret-Namen. Sie mögen pedantisch wirken, dienen aber der Konsistenz und Kompatibilität.
Für alle Variablen und Secrets:
[a-zA-Z0-9_])GITHUB_ beginnen (reserviert für Default
Variables)Size Limits:
Diese Limits sind großzügig für normale Nutzung, können aber bei exzessiver Verwendung von JSON-Configs oder Multi-Megabyte-Secrets zum Problem werden. Für große Datenmengen – etwa ML-Modell-Weights oder große Config-Files – ist ein externer Speicher (S3, Artifact Storage) der bessere Weg.
Environment-Matrix aus JSON-Output: Ein häufiges Pattern für dynamische Workflows – ein Job berechnet, gegen welche Environments deployed werden soll, und gibt das als JSON-Array aus. Der Deploy-Job nutzt es als Matrix:
jobs:
determine-targets:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
if [ "${{ github.ref_name }}" == "main" ]; then
echo 'matrix=["staging","production"]' >> $GITHUB_OUTPUT
else
echo 'matrix=["development"]' >> $GITHUB_OUTPUT
fi
deploy:
needs: determine-targets
strategy:
matrix:
environment: ${{ fromJSON(needs.determine-targets.outputs.matrix) }}
runs-on: ubuntu-latest
environment: ${{ matrix.environment }}
steps:
- name: Deploy to ${{ matrix.environment }}
run: echo "Deploying..."Composite Secrets für Multi-Value-Credentials: AWS
braucht Access Key ID und Secret Key. Statt zwei separate
Secrets in jedem Step zu übergeben, definieren wir sie einmal im
Job-env:
jobs:
deploy:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
steps:
- name: Upload to S3
run: aws s3 sync ./dist s3://bucket
- name: Invalidate CloudFront
run: aws cloudfront create-invalidation --distribution-id ${{ vars.CF_DIST_ID }}Alle Steps erben die AWS-Credentials, ohne sie explizit zu deklarieren. Das reduziert Boilerplate und Fehlerquellen.
Version-Strings aus Context-Daten: Semantische Versionen lassen sich aus Workflow-Metadaten konstruieren:
steps:
- name: Generate version
id: version
run: |
if [[ "${{ github.ref }}" =~ ^refs/tags/v(.*)$ ]]; then
echo "version=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
echo "version=0.0.0-dev.${{ github.run_number }}" >> $GITHUB_OUTPUT
fi
- name: Build with version
run: |
echo "Building version ${{ steps.version.outputs.version }}"Für Tags nutzen wir die Tag-Version, für Development-Builds eine synthetische Version aus der Run-Number. Das hält Versionen eindeutig und nachvollziehbar.
Secrets in Logs debuggen: Der Klassiker – ein
verzweifelter echo ${{ secrets.MY_SECRET }}, um zu prüfen,
ob das Secret korrekt ist. GitHub redacted es im Log, aber das Muster
ist gefährlich. Besser: Prüfen, ob das Secret existiert
(nicht-leer ist), nicht seinen Wert ausgeben.
# Schlecht:
- run: echo "Secret is: ${{ secrets.MY_SECRET }}"
# Gut:
- name: Verify secret exists
if: ${{ secrets.MY_SECRET == '' }}
run: |
echo "ERROR: MY_SECRET is not set"
exit 1Kontext-Missbrauch in Shell-Variablen: Ein subtiler Fehler – Context-Properties in Strings einbetten, die dann in der Shell evaluiert werden:
# Unsicher - Command Injection möglich:
- run: echo "Processing ${{ github.event.head_commit.message }}"
# Sicher - via env-Variable übergeben:
- env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: echo "Processing $COMMIT_MSG"Im ersten Beispiel wird die Commit-Message direkt in den Shell-Befehl
interpoliert. Enthält sie Shell-Metacharacters (;,
|, $()), können sie ausgeführt werden. Im
zweiten Beispiel landet sie als Umgebungsvariable im Shell-Environment –
sicher vor Injection.
Overengineering mit Ausdrücken: Komplexe Logik
gehört in Scripts, nicht in YAML-Ausdrücke. Wenn eine
if-Bedingung drei Zeilen lang wird und fünf Operatoren
verknüpft, ist es Zeit für ein dediziertes Script:
# Überkompliziert:
- if: |
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
!contains(github.event.head_commit.message, '[skip ci]') &&
success()
# Besser:
- name: Check deployment conditions
id: check
run: |
# Logik in Shell mit Kommentaren und Debuggability
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
# ... komplexe Prüflogik ...
echo "should_deploy=true" >> $GITHUB_OUTPUT
fi
- if: steps.check.outputs.should_deploy == 'true'Lesbarkeit und Wartbarkeit schlagen kompakte YAML-Ausdrücke. Komplexe Bedingungen profitieren von expliziter Programmlogik mit Kommentaren, Logging und Testbarkeit – alles Dinge, die in YAML-Expressions fehlen.