8 Jobs orchestrieren

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.

8.1 Job-Abhängigkeiten: Sequenzen bauen mit needs

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.sh

Diese Pipeline ist eine Kette: buildtestdeploy. 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.

8.1.1 Mehrere Abhängigkeiten: Fan-In-Pattern

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.sh

Hier 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).

8.1.2 Daten zwischen Jobs teilen: Outputs und der needs-Kontext

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.sh

Der Datenfluss ist explizit:

  1. Build-Steps schreiben in $GITHUB_OUTPUT
  2. Build-Job mappt Step-Outputs zu Job-Outputs
  3. Deploy-Job liest über needs.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.

8.1.3 Bedingte Fortsetzung: always() und failure()

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.

8.2 Matrix-Strategien: Variationen parallel ausführen

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 test

Das 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.

8.2.1 Mehrdimensionale Matrizen: Kartesisches Produkt

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 test

Das 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.

8.2.2 Exclude: Ungewollte Kombinationen vermeiden

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 test

Das 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.

8.2.3 Include: Spezielle Kombinationen hinzufügen

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 test

Base-Matrix ergibt vier Jobs (2 OS × 2 Node). include fügt hinzu:

  1. ubuntu-latest + node-version: 22 bekommt experimental: true – das matched eine existierende Kombination, erweitert sie also nur.
  2. 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.

8.2.4 Dynamische Matrizen: fromJSON() und Outputs

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 test

Auf 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.

8.3 Fehlerbehandlung und Parallelität steuern

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?

8.3.1 fail-fast: Abbrechen bei erstem Fehler

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 test

Mit 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.

8.3.2 max-parallel: Parallelität begrenzen

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 test

Maximal 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.

8.3.3 continue-on-error: Optionale Jobs in Matrizen

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 test

Die 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.”

8.4 Concurrency: Workflow-Runs begrenzen

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 build

Die 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:

Welche Variante ist besser? Kommt drauf an:

cancel-in-progress: true ist sinnvoll für:

cancel-in-progress: false ist sinnvoll für:

8.4.1 Job-Level Concurrency

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.sh

Nur 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.sh

Auf 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.

8.4.2 Concurrency-Groups clever nutzen

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.

8.5 Bedingte Ausführung: if-Bedingungen auf Job-Ebene

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.sh

Der 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.

8.5.1 Kontexte in if-Bedingungen

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).

8.5.2 Bedingte Jobs basierend auf Changes

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=frontend

Der 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.

8.5.3 if mit Status-Funktionen kombiniert

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.sh

Das baut eine Deployment-Pipeline mit automatischem Rollback bei Prod-Failures.

8.6 Praktische Orchestrierungsmuster

Die einzelnen Mechanismen sind Werkzeuge. Patterns zeigen, wie man sie kombiniert.

8.6.1 Multi-Stage-Pipeline mit Fan-Out und Fan-In

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.

8.6.2 Matrix mit bedingten Deployments

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.sh

Drei Builds, drei Artifacts. Deploy nutzt nur das Linux-Artifact – das andere waren für Verifizierung, nicht für Production.

8.6.3 Dynamic Matrix für Monorepo

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.

8.6.4 Kombinierte Concurrency und Matrix

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.sh

Tests canceln sich bei neuen Pushes (schnelles Feedback). Deployments warten aufeinander (keine Race-Conditions). Zwei Concurrency-Gruppen, zwei Verhaltensweisen, ein Workflow.