Ein einzelner Job ist linear: Steps laufen nacheinander ab, fertig. Aber echte CI/CD-Pipelines sind selten so simpel. Code muss gegen mehrere Node.js-Versionen getestet werden. Builds laufen parallel für verschiedene Plattformen. Deployments warten auf erfolgreiche Tests. Mehrere Feature-Branch-Pushes konkurrieren um dieselbe Staging-Umgebung.
Das ist Job-Orchestrierung – die Kunst, mehrere Jobs so zu koordinieren, dass sie effizient, sicher und vorhersagbar zusammenarbeiten. GitHub Actions bietet dafür mächtige Mechanismen: Job-Abhängigkeiten für sequenzielle Pipelines, Matrix-Strategien für parallele Variationen, Concurrency-Control für Ressourcen-Management, und bedingte Ausführung für intelligente Entscheidungen zur Laufzeit.
Dieses Kapitel führt von einfachen Job-Ketten zu komplexen, dynamischen Orchestrierungen. Wir beginnen mit dem Fundament – zwei Jobs, die aufeinander warten – und enden bei Matrix-Builds mit dutzenden parallelen Varianten und ausgeklügelter Fehlerbehandlung. Die Komplexität steigt graduell, aber das Prinzip bleibt: Jobs sind die Bausteine, Orchestrierung ist das Muster, nach dem wir sie zusammenfügen.
Jobs laufen standardmäßig parallel. Sobald ein Workflow startet, beginnen alle Jobs gleichzeitig – jeder auf seinem eigenen Runner, jeder in seiner eigenen Umgebung. Das ist effizient, wenn Jobs unabhängig sind. Aber CI/CD-Pipelines haben oft natürliche Reihenfolgen: Erst bauen, dann testen, dann deployen. Erst Linting, dann Unit-Tests, dann Integration-Tests.
Das needs-Keyword definiert diese Abhängigkeiten. Ein
Job mit needs: build wartet, bis der build-Job
erfolgreich abgeschlossen ist. Die Semantik ist klar: “Ich brauche X,
bevor ich starten kann.”
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- run: ./deploy.shDiese Pipeline ist eine Kette: build → test
→ deploy. GitHub startet test erst, wenn
build mit Exit-Code 0 beendet. deploy wartet
auf test. Schlägt build fehl, werden
test und deploy nie gestartet – sie erscheinen
im Workflow-Log als “skipped”. Das ist fail-fast auf Job-Ebene: Ein
Fehler propagiert durch die Abhängigkeitskette.
Ein Job kann auf mehrere vorherige Jobs warten. Das
needs-Array macht aus parallelen Pfaden wieder
sequenzielle:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: npm run lint
unit-tests:
runs-on: ubuntu-latest
steps:
- run: npm run test:unit
integration-tests:
runs-on: ubuntu-latest
steps:
- run: npm run test:integration
deploy:
needs: [lint, unit-tests, integration-tests]
runs-on: ubuntu-latest
steps:
- run: ./deploy.shHier laufen lint, unit-tests und
integration-tests parallel – keinerlei Abhängigkeit
zwischen ihnen. Aber deploy braucht alle drei. GitHub
wartet, bis der langsamste der drei fertig ist, und startet dann
deploy. Schlägt einer der drei fehl, wird
deploy geskipped.
Das ist ein Fan-In: Mehrere parallele Jobs konvergieren zu einem nachgelagerten Job. Es maximiert Parallelität (die drei Tests laufen gleichzeitig) und erzwingt trotzdem Korrektheit (Deployment nur bei allen grünen Tests).
Jobs sind isoliert. Sie laufen auf verschiedenen Runnern, haben eigene Filesysteme, eigene Umgebungsvariablen. Aber manchmal muss Information fließen: Der Build-Job generiert eine Version-ID, der Deploy-Job braucht sie. Der Test-Job berechnet Code-Coverage, der Report-Job visualisiert sie.
Job-Outputs sind der Mechanismus. Ein Job definiert Outputs, die von
Steps kommen. Abhängige Jobs lesen diese Outputs über den
needs-Kontext:
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version-step.outputs.version }}
artifact-url: ${{ steps.upload.outputs.url }}
steps:
- name: Generate version
id: version-step
run: echo "version=1.0.${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Build and upload
id: upload
run: |
npm run build
url="https://artifacts.example.com/build-${{ github.sha }}.tar.gz"
echo "url=$url" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy version ${{ needs.build.outputs.version }}
run: |
echo "Deploying version ${{ needs.build.outputs.version }}"
curl -O ${{ needs.build.outputs.artifact-url }}
./deploy.shDer Datenfluss ist explizit:
$GITHUB_OUTPUTneeds.build.outputs.*Wichtig: Outputs sind Strings, maximal 1 MB pro Job, 50 MB gesamt pro
Workflow. Für große Daten (Build-Artefakte, Test-Results) nutzen wir
Actions wie actions/upload-artifact – dazu mehr im Kapitel
zu Artefakten.
Standardmäßig gilt: Wenn ein Job fehlschlägt, werden alle abhängigen Jobs geskipped. Das ist sinnvoll für Production-Deployments – ein fehlgeschlagener Test sollte kein Deployment auslösen. Aber nicht jeder nachgelagerte Job ist deployment-kritisch. Cleanup-Tasks sollten immer laufen. Notification-Jobs sollen gerade bei Fehlern aktiv werden.
Status-Funktionen in if-Bedingungen ändern das
Verhalten:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm run build
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: npm test
cleanup:
needs: [build, test]
if: always()
runs-on: ubuntu-latest
steps:
- run: ./cleanup.sh
notify-failure:
needs: [build, test]
if: failure()
runs-on: ubuntu-latest
steps:
- run: |
curl -X POST https://slack.webhook.url \
-d '{"text": "Build failed!"}'Der cleanup-Job läuft immer – egal ob build
oder test erfolgreich waren, fehlschlugen oder gecancelt
wurden. if: always() überschreibt das
Default-Verhalten.
Der notify-failure-Job läuft nur, wenn mindestens einer
der vorherigen Jobs fehlschlug. if: failure() prüft den
Status aller Jobs im needs-Array.
| Status-Funktion | Bedeutung | Typischer Einsatz |
|---|---|---|
success() |
Alle vorherigen erfolgreich (Default) | Production-Deployments |
failure() |
Mindestens einer fehlgeschlagen | Error-Notifications, Rollbacks |
always() |
Unabhängig vom Status | Cleanup, Logs hochladen |
cancelled() |
Workflow wurde abgebrochen | Cleanup bei manueller Cancellation |
Status-Funktionen können kombiniert werden:
if: ${{ always() && needs.build.result == 'success' }}Das läuft immer, aber nur wenn build erfolgreich war –
nützlich für Reports, die Build-Daten brauchen, aber auch bei
Test-Fehlern laufen sollen.
Matrix-Builds sind die Antwort auf eine simple Frage: Wie teste ich gegen Node.js 18, 20 und 22 ohne drei Jobs zu schreiben? Wie baue ich für Linux, macOS und Windows ohne Code-Duplikation? Matrix-Strategien definieren Variablen und ihre Werte. GitHub generiert für jede Kombination automatisch einen Job.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm testDas matrix definiert eine Dimension:
node-version mit drei Werten. GitHub erstellt drei Jobs,
jeder mit einem anderen matrix.node-version. Der
${{ matrix.node-version }}-Ausdruck wird in jedem Job durch
den konkreten Wert ersetzt.
Im Workflow-Log sehen wir: - test (18) -
test (20) - test (22)
Alle drei laufen parallel (sofern Runner verfügbar sind). Das ist drastisch schneller als sequenzielle Tests – statt 15 Minuten für drei Node-Versionen hintereinander dauert es 5 Minuten parallel.
Mehrere Variablen erzeugen alle Kombinationen:
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm testDas ergibt 3 × 3 = 9 Jobs:
| Job | OS | Node |
|---|---|---|
| 1 | ubuntu-latest | 18 |
| 2 | ubuntu-latest | 20 |
| 3 | ubuntu-latest | 22 |
| 4 | windows-latest | 18 |
| 5 | windows-latest | 20 |
| 6 | windows-latest | 22 |
| 7 | macos-latest | 18 |
| 8 | macos-latest | 20 |
| 9 | macos-latest | 22 |
Jeder Job hat eigene matrix.os und
matrix.node-version Werte. Das
runs-on: ${{ matrix.os }} macht die Runner-Wahl dynamisch –
ein Job läuft auf Ubuntu, einer auf Windows, einer auf macOS.
Achtung beim Wachstum: Eine Matrix mit 5 OS-Werten, 4 Node-Versionen und 3 Datenbanken erzeugt 60 Jobs. GitHub erlaubt maximal 256 Jobs pro Matrix. Bei zu vielen Dimensionen stößt man schnell an Limits – oder Runner-Kapazität.
Nicht alle Kombinationen ergeben Sinn. Vielleicht unterstützt eine
Library nur Node 20+ auf Windows. Vielleicht ist macOS-Testing nur für
die neueste Node-Version nötig (weil teuer). exclude
entfernt spezifische Kombinationen:
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
exclude:
- os: windows-latest
node-version: 18
- os: macos-latest
node-version: 18
- os: macos-latest
node-version: 20
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm testDas entfernt drei Jobs aus den ursprünglichen neun:
Übrig bleiben sechs Jobs. exclude wertet partiell: Es
reicht, wenn die angegebenen Key-Value-Paare matchen. Weitere
Matrix-Properties werden ignoriert.
Manchmal brauchen wir Kombinationen außerhalb des kartesischen
Produkts. Oder wir wollen bestimmten Kombinationen extra Properties
geben. include ist das Gegenstück zu
exclude:
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [20, 22]
include:
- os: ubuntu-latest
node-version: 22
experimental: true
- os: macos-latest
node-version: 22
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental == true }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm testBase-Matrix ergibt vier Jobs (2 OS × 2 Node). include
fügt hinzu:
ubuntu-latest + node-version: 22 bekommt
experimental: true – das matched eine existierende
Kombination, erweitert sie also nur.macos-latest + node-version: 22 ist neu –
das wird als fünfter Job hinzugefügt.Nach include haben wir: - ubuntu-latest, Node 20 -
ubuntu-latest, Node 22 (mit experimental: true) -
windows-latest, Node 20 - windows-latest, Node 22 - macos-latest, Node
22
Die experimental-Property wird in
continue-on-error genutzt. Der Ubuntu-22-Job darf
fehlschlagen, ohne den Workflow zu stoppen – perfekt für Nightly-Builds
oder Preview-Versionen.
Matrizen müssen nicht statisch sein. Ein Job kann eine Matrix
berechnen und als JSON-Output ausgeben. Ein nachfolgender Job konsumiert
sie via fromJSON():
jobs:
prepare-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Generate matrix
id: set-matrix
run: |
if [ "${{ github.ref_name }}" == "main" ]; then
echo 'matrix={"os":["ubuntu-latest","windows-latest","macos-latest"],"node":[18,20,22]}' >> $GITHUB_OUTPUT
else
echo 'matrix={"os":["ubuntu-latest"],"node":[22]}' >> $GITHUB_OUTPUT
fi
test:
needs: prepare-matrix
strategy:
matrix: ${{ fromJSON(needs.prepare-matrix.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm testAuf main testen wir umfassend (9 Kombinationen). Auf
Feature-Branches nur schnell (1 Kombination). Die Matrix-Logik ist Code,
nicht Configuration – das erlaubt komplexe Entscheidungen basierend auf
Commits, Labels, oder externen APIs.
Ein Praxis-Beispiel: Eine Matrix aus geänderten Packages in einem Monorepo:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.changed.outputs.packages }}
steps:
- uses: actions/checkout@v4
- name: Detect changed packages
id: changed
run: |
packages=$(git diff --name-only HEAD^ | grep '^packages/' | cut -d'/' -f2 | sort -u | jq -R -s -c 'split("\n")[:-1]')
echo "packages=$packages" >> $GITHUB_OUTPUT
test:
needs: detect-changes
if: ${{ needs.detect-changes.outputs.packages != '[]' }}
strategy:
matrix:
package: ${{ fromJSON(needs.detect-changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- run: npm test --workspace=packages/${{ matrix.package }}Nur geänderte Packages werden getestet. Das skaliert für Monorepos mit hunderten Packages – statt jedes Mal alles zu testen, fokussieren wir auf das Relevante.
Matrizen mit vielen Jobs werfen Fragen auf: Was passiert, wenn einer fehlschlägt? Sollen alle anderen abgebrochen werden oder weiterlaufen? Und wie viele Jobs dürfen gleichzeitig laufen – sollen 20 Jobs um 5 Runner konkurrieren, oder steuern wir das?
Standardmäßig ist fail-fast: true. Sobald ein Matrix-Job
fehlschlägt, bricht GitHub alle laufenden und queued Jobs der Matrix ab.
Das spart Runner-Zeit: Wenn Ubuntu-Node-18 scheitert, ist klar, dass
etwas fundamental kaputt ist – Windows-Node-18 wird auch scheitern.
Aber nicht immer. Manchmal sind Fehler plattform- oder versions-spezifisch. Ein Flaky-Test schlägt auf Windows fehl, aber nicht auf Linux. Node 18 hat einen Bug, aber 20 und 22 laufen. In diesen Fällen wollen wir alle Jobs sehen:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- run: npm testMit fail-fast: false laufen alle neun Jobs bis zum Ende
– egal wie viele fehlschlagen. Das gibt vollständiges Feedback: “Node 18
schlägt auf allen Plattformen fehl, Node 20 nur auf Windows, Node 22
überall grün.”
Der Trade-off ist Runner-Zeit. Wenn der erste Job in Minute 1 fehlschlägt und fail-fast aus ist, laufen die anderen 8 Jobs noch 10 Minuten weiter – potentiell verschwendet, wenn der Fehler systematisch ist.
GitHub startet so viele Matrix-Jobs parallel, wie Runner verfügbar sind. Bei großen Matrizen kann das problematisch sein:
max-parallel setzt ein Limit:
jobs:
test:
strategy:
max-parallel: 3
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- run: npm testMaximal drei Jobs laufen gleichzeitig. Die anderen sechs warten in der Queue. Sobald einer der drei fertig ist, startet der nächste. Das gibt Kontrolle über Last und Kosten.
Ein häufiges Pattern: max-parallel: 1 für Deployments zu
einer geteilten Staging-Umgebung. Nur ein Job deployt gleichzeitig,
Konflikte sind unmöglich.
Manchmal ist ein Teil der Matrix experimental. Nightly-Builds, Beta-Versionen, neue Plattformen. Sie sollen getestet werden, aber Fehler sollen den Workflow nicht zum Scheitern bringen.
continue-on-error auf Job-Ebene markiert Jobs als
optional:
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
experimental: [false]
include:
- node-version: 23
experimental: true
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm testDie ersten drei Jobs (Node 18, 20, 22) haben
experimental: false, also
continue-on-error: false. Sie müssen grün sein. Der vierte
Job (Node 23) hat experimental: true, also
continue-on-error: true. Er darf rot sein – der Workflow
bleibt trotzdem grün.
Das gibt uns Feedback zu experimentellen Setups, ohne Stabilität zu
opfern. Node 23 Nightly schlägt jeden zweiten Tag fehl? Kein Problem,
wird nicht als Critical behandelt. Sobald Node 23 stable ist, entfernen
wir das experimental-Flag.
Kombination mit fail-fast ist interessant:
strategy:
fail-fast: true
matrix:
node-version: [18, 20, 22, 23]
experimental: [false, false, false, true]
continue-on-error: ${{ matrix.experimental }}Schlägt Node 18, 20 oder 22 fehl, bricht fail-fast alle
ab – inklusive Node 23. Schlägt nur Node 23 fehl, laufen die anderen
weiter, und der Workflow ist grün. Das ist: “Kritische Jobs sofort
abbrechen, optionale ignorieren.”
Matrix-Jobs sind Parallelität innerhalb eines Workflow-Runs.
Aber was ist mit mehreren Runs desselben Workflows? Ein
Developer pusht zu Branch feature-x, startet einen
Workflow-Run. 30 Sekunden später pusht er wieder – ein zweiter Run
startet. Beide konkurrieren um Runner, beide deployen möglicherweise zu
derselben Test-Umgebung, beide generieren Artifacts mit überlappenden
Namen.
Concurrency-Groups lösen das Problem. Sie definieren, dass nur ein Run einer bestimmten “Gruppe” gleichzeitig laufen darf. Weitere Runs werden entweder queued oder abgebrochen.
on:
push:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm run buildDie group ist ein String – hier zusammengesetzt aus
Workflow-Name und Branch-Ref. Für den main-Branch ist die
Gruppe My Workflow-refs/heads/main. Für
develop ist sie anders.
Wenn ein zweiter Push zu main kommt, während der erste
Run noch läuft:
cancel-in-progress: true → Der alte Run wird
abgebrochen, der neue startet.cancel-in-progress: false (Default) → Der neue Run
wartet, bis der alte fertig ist.Welche Variante ist besser? Kommt drauf an:
cancel-in-progress: true ist sinnvoll für:
cancel-in-progress: false ist sinnvoll für:
Concurrency kann auch auf Job-Ebene definiert werden:
jobs:
deploy-staging:
runs-on: ubuntu-latest
concurrency:
group: staging-deployment
cancel-in-progress: false
steps:
- run: ./deploy-to-staging.sh
deploy-production:
runs-on: ubuntu-latest
concurrency:
group: production-deployment
cancel-in-progress: false
steps:
- run: ./deploy-to-production.shNur ein Job kann zur staging-deployment-Gruppe gehören.
Wenn zwei Workflow-Runs gleichzeitig laufen und beide versuchen, zu
Staging zu deployen, wartet einer. Das verhindert Race-Conditions bei
Deployments.
Job-Concurrency ist unabhängig von Workflow-Concurrency. Man kann beides kombinieren:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test
deploy:
needs: test
concurrency:
group: deploy-production
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- run: ./deploy.shAuf Workflow-Level: Neue Pushes canceln alte Runs. Auf Job-Level: Deployments warten aufeinander, werden nicht gecancelt. Das gibt uns: Schnelles Feedback bei Tests, sichere Serialisierung bei Deployments.
Die Group-ID kann Ausdrücke enthalten. Das erlaubt präzise Kontrolle:
Pro Branch: group: ci-${{ github.ref }}
- Jeder Branch hat eigene Concurrency. Pushes zu main
canceln sich gegenseitig, aber nicht Pushes zu develop.
Pro Pull Request:
group: pr-${{ github.event.pull_request.number }} - Jeder
PR hat eigene Concurrency. Commits zu PR #42 canceln nur innerhalb von
#42.
Mit Fallback:
group: ${{ github.head_ref || github.run_id }} - Bei PRs:
nutze Branch-Name (head_ref). - Bei Pushes: nutze Run-ID (jeder Run ist
unique).
Bedingte Cancellation:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: ${{ !contains(github.ref, 'release/') }}Auf Feature-Branches werden Runs gecancelt. Auf Release-Branches
(release/1.0, release/2.0) nicht – wir wollen
vollständige Tests.
Nicht jeder Job soll bei jedem Run laufen. Deployments nur auf
main, nicht auf Feature-Branches. E2E-Tests nur nachts
(weil langsam und teuer). Security-Scans nur bei Änderungen in
Dependencies.
if-Bedingungen auf Job-Ebene entscheiden zur Laufzeit,
ob ein Job startet:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: ./deploy.shDer deploy-Job läuft nur, wenn der Ref
refs/heads/main ist. Auf Feature-Branches erscheint er als
“skipped”.
Wichtig: if wird evaluiert, bevor der Job an
einen Runner geschickt wird. Der Runner sieht geskippte Jobs nie. Das
spart Runner-Zeit – kein Runner wird allokiert, nur um dann
festzustellen, dass die Bedingung false ist.
if kann auf viele Kontexte zugreifen:
github, needs, vars,
inputs, strategy, matrix.
Kombiniert mit Operatoren und Funktionen ergeben sich mächtige
Patterns:
Nur für bestimmte Events:
if: github.event_name == 'push'Nur für bestimmte Actors:
if: github.actor == 'dependabot[bot]'Branch-Patterns:
if: startsWith(github.ref, 'refs/heads/release/')Label-basiert (bei PRs):
if: contains(github.event.pull_request.labels.*.name, 'deploy-preview')Kombinationen:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'Nur bei Direct-Pushes zu Main, nicht bei PR-Merges (die sind auch push-Events, aber mit anderem Context).
Ein häufiges Pattern: Backend-Jobs nur bei Backend-Changes, Frontend-Jobs nur bei Frontend-Changes. Das erfordert path-Filtering plus dynamische Conditions:
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
backend-changed: ${{ steps.filter.outputs.backend }}
frontend-changed: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
test-backend:
needs: check-changes
if: needs.check-changes.outputs.backend-changed == 'true'
runs-on: ubuntu-latest
steps:
- run: npm test --workspace=backend
test-frontend:
needs: check-changes
if: needs.check-changes.outputs.frontend-changed == 'true'
runs-on: ubuntu-latest
steps:
- run: npm test --workspace=frontendDer check-changes-Job analysiert Diff, setzt Outputs.
Die Test-Jobs laufen nur, wenn relevante Pfade geändert wurden. Bei
einem Backend-only-PR läuft test-frontend nicht – Zero
Runner-Zeit verschwendet.
Status-Funktionen in if schaffen komplexe Logik:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm run build
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: npm test
deploy-staging:
needs: [build, test]
if: success() && github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- run: ./deploy-staging.sh
deploy-production:
needs: [build, test]
if: success() && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: ./deploy-production.sh
rollback:
needs: [deploy-production]
if: failure()
runs-on: ubuntu-latest
steps:
- run: ./rollback.shdeploy-staging läuft nur auf develop, und
nur wenn build und test grün sind.deploy-production läuft nur auf main, und
nur bei Success.rollback läuft nur, wenn deploy-production
fehlschlägt.Das baut eine Deployment-Pipeline mit automatischem Rollback bei Prod-Failures.
Die einzelnen Mechanismen sind Werkzeuge. Patterns zeigen, wie man sie kombiniert.
Eine typische CI/CD-Pipeline hat Stages: Build → Tests (parallel) → Deploy.
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Generate version
id: version
run: echo "version=1.0.${{ github.run_number }}" >> $GITHUB_OUTPUT
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: build-artifact
path: dist/
test-unit:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-artifact
path: dist/
- run: npm test:unit
test-integration:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-artifact
path: dist/
- run: npm test:integration
test-e2e:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-artifact
path: dist/
- run: npm test:e2e
deploy:
needs: [build, test-unit, test-integration, test-e2e]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-artifact
path: dist/
- run: ./deploy.sh ${{ needs.build.outputs.version }}Build läuft einmal, generiert Artifact. Tests laufen parallel, alle nutzen das Artifact. Deploy wartet auf alle Tests, läuft nur auf Main. Effizient und sauber strukturiert.
Cross-Platform-Build, aber Deployment nur von Linux:
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }}
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
with:
name: build-ubuntu-latest
path: dist/
- run: ./deploy.shDrei Builds, drei Artifacts. Deploy nutzt nur das Linux-Artifact – das andere waren für Verifizierung, nicht für Production.
Testen nur geänderte Packages:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
package-a: packages/a/**
package-b: packages/b/**
package-c: packages/c/**
test:
needs: changes
if: needs.changes.outputs.packages != '[]'
strategy:
matrix:
package: ${{ fromJSON(needs.changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test --workspace=packages/${{ matrix.package }}Nur geänderte Packages spawnen Matrix-Jobs. Bei großen Monorepos mit 50 Packages, von denen 3 geändert wurden, laufen 3 Jobs statt 50.
Parallel testen, serial deployen:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
runs-on: ubuntu-latest
steps:
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
concurrency:
group: deploy-production
cancel-in-progress: false
if: github.ref == 'refs/heads/main'
steps:
- run: ./deploy.shTests canceln sich bei neuen Pushes (schnelles Feedback). Deployments warten aufeinander (keine Race-Conditions). Zwei Concurrency-Gruppen, zwei Verhaltensweisen, ein Workflow.