10 Workflows wiederverwenden

Das DRY-Prinzip (Don’t Repeat Yourself) gilt in der Softwareentwicklung als Grundregel – und CI/CD-Pipelines sind da keine Ausnahme. Wer dieselben Build-, Test- und Deployment-Schritte über dutzende Repositories hinweg dupliziert, schafft eine Wartungshölle. GitHub Actions bietet drei komplementäre Mechanismen zur Wiederverwendung: Reusable Workflows für vollständige Pipeline-Templates, Composite Actions für Step-Sequenzen und lokale Actions für repository-spezifische Bausteine.

Die Kunst besteht darin zu wissen, wann man welchen Ansatz verwendet – und wie man sie kombiniert, um maximale Flexibilität bei minimaler Komplexität zu erreichen.

10.1 Reusable Workflows: Pipeline-Templates auf Job-Ebene

10.1.1 Das Konzept

Ein Reusable Workflow ist eine YAML-Datei, die nicht durch Events wie push oder pull_request getriggert wird, sondern durch workflow_call. Sie definiert einen oder mehrere Jobs, die von anderen Workflows aufgerufen werden können – ähnlich einer Funktion, die von anderen Funktionen aufgerufen wird.

Der Unterschied zu normalen Workflows: Reusable Workflows leben in .github/workflows/ (genau wie normale Workflows), werden aber nicht automatisch ausgeführt. Sie warten darauf, von einem Caller-Workflow referenziert zu werden.

# .github/workflows/build-and-test.yml
name: Build and Test Template

on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
      run-integration-tests:
        required: false
        type: boolean
        default: false
    secrets:
      npm-token:
        required: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
        env:
          NPM_TOKEN: ${{ secrets.npm-token }}
      - run: npm run build
      - run: npm test
      
  integration-test:
    if: ${{ inputs.run-integration-tests }}
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:integration

Dieser Reusable Workflow definiert zwei Inputs (node-version, run-integration-tests) und ein Secret (npm-token). Er führt immer Unit-Tests aus, Integrationstests nur optional.

10.1.2 Aufruf und Parameterübergabe

Ein Caller-Workflow referenziert den Reusable Workflow auf Job-Ebene mit uses:

# .github/workflows/ci.yml
name: CI Pipeline

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test-node-18:
    uses: ./.github/workflows/build-and-test.yml
    with:
      node-version: '18'
      run-integration-tests: true
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

Die Syntax ist kompakt: uses auf Job-Ebene (nicht Step-Ebene!), with für Inputs, secrets für Secrets. Der Pfad ./.github/workflows/build-and-test.yml referenziert eine Datei im selben Repository. Für andere Repositories:

uses: myorg/shared-workflows/.github/workflows/build-and-test.yml@v2

Das @v2 ist eine Git-Referenz – ein Tag, Branch oder SHA. Best Practice: SHA-Pinning für kritische Workflows, Tags für einfachere Updates.

10.1.3 Secrets: Explizit oder inherit

Secrets können explizit übergeben werden (wie oben) oder mit secrets: inherit alle verfügbaren Secrets weitergegeben:

jobs:
  test:
    uses: ./.github/workflows/build-and-test.yml
    with:
      node-version: '20'
    secrets: inherit

Das ist bequem, birgt aber ein Risiko: Der Reusable Workflow erhält Zugriff auf alle Secrets des Callers, auch solche, die er gar nicht braucht. In Organizations mit strikten Security-Policies sollte man Secrets explizit übergeben.

Wichtig bei Nesting: Secrets werden nicht automatisch transitiv weitergegeben. In einer Kette A → B → C muss A die Secrets an B übergeben, und B muss sie explizit an C weiterleiten:

# Workflow A
jobs:
  call-b:
    uses: org/repo/.github/workflows/b.yml@main
    secrets: inherit

# Workflow B  
jobs:
  call-c:
    uses: org/repo/.github/workflows/c.yml@main
    secrets:
      token: ${{ secrets.SOME_TOKEN }}  # explizite Weiterleitung

10.1.4 Outputs: Daten zurückgeben

Reusable Workflows können Outputs definieren, die der Caller nutzen kann. Das erfordert ein zweistufiges Mapping: Step-Output → Job-Output → Workflow-Output.

# Reusable Workflow
on:
  workflow_call:
    outputs:
      build-version:
        description: "Version of the build artifact"
        value: ${{ jobs.build.outputs.version }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.get-version.outputs.version }}
    steps:
      - id: get-version
        run: echo "version=$(cat package.json | jq -r .version)" >> $GITHUB_OUTPUT

Der Caller greift darauf zu wie auf Job-Outputs:

jobs:
  build:
    uses: ./.github/workflows/build.yml
    
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ needs.build.outputs.build-version }}"

10.1.5 Matrix-Strategien mit Reusable Workflows

Matrix-Jobs können Reusable Workflows aufrufen. Das ist mächtig für Cross-Platform-Tests:

jobs:
  test-matrix:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
    uses: ./.github/workflows/test.yml
    with:
      os: ${{ matrix.os }}
      node-version: ${{ matrix.node }}

Jede Kombination von OS und Node-Version führt den Reusable Workflow einmal aus. Das Resultat: 9 Jobs aus einer Zeile YAML.

10.1.6 Verschachtelung und Limits

Reusable Workflows können andere Reusable Workflows aufrufen – bis zu 10 Ebenen tief. Das ist mehr als ausreichend für die meisten Szenarien, aber es gibt eine wichtige Einschränkung: Permissions können nur reduziert, nie erhöht werden.

Workflow C kann nicht mehr Permissions haben als Workflow B. Die Chain kann nur restriktiver werden, nie permissiver.

10.2 Composite Actions: Step-Sequenzen kapseln

10.2.1 Der Unterschied zu Reusable Workflows

Während Reusable Workflows auf Job-Ebene operieren und mehrere Jobs enthalten können, arbeiten Composite Actions auf Step-Ebene. Eine Composite Action ist eine Sequenz von Steps, die als einzelner Step in einem Job aufgerufen wird.

Die action.yml einer Composite Action sieht so aus:

name: Setup Node Environment
description: Installs Node, restores cache, and installs dependencies

inputs:
  node-version:
    description: Node.js version
    required: true
  cache-dependency-path:
    description: Path to package-lock.json
    required: false
    default: '**/package-lock.json'

runs:
  using: composite
  steps:
    - name: Setup Node
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm
        cache-dependency-path: ${{ inputs.cache-dependency-path }}
    
    - name: Install dependencies
      shell: bash
      run: npm ci
      
    - name: Verify installation
      shell: bash
      run: |
        echo "Node version: $(node --version)"
        echo "NPM version: $(npm --version)"

Das runs.using: composite ist der entscheidende Marker. Composite Actions können run-Commands oder andere Actions aufrufen, aber keine Jobs definieren.

10.2.2 Lokale vs. Shared Composite Actions

Composite Actions können lokal im selben Repository oder shared in einem separaten Repository leben.

Lokale Action (im selben Repo):

my-repo/
├── .github/
│   ├── actions/
│   │   └── setup-env/
│   │       └── action.yml
│   └── workflows/
│       └── ci.yml

Aufruf im Workflow:

steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup-env
    with:
      node-version: '20'

Shared Action (in anderem Repo):

steps:
  - uses: myorg/shared-actions/setup-env@v1
    with:
      node-version: '20'

Der Vorteil lokaler Actions: Sie versionieren automatisch mit dem Repository. Kein Dependency-Management notwendig. Der Nachteil: Keine Wiederverwendung über Repository-Grenzen hinweg.

10.2.3 Inputs, Outputs und Environment

Composite Actions können Inputs empfangen und Outputs zurückgeben:

name: Build and Extract Version
inputs:
  build-command:
    required: true
outputs:
  version:
    description: Extracted version
    value: ${{ steps.extract.outputs.version }}

runs:
  using: composite
  steps:
    - name: Build
      shell: bash
      run: ${{ inputs.build-command }}
      
    - name: Extract version
      id: extract
      shell: bash
      run: |
        VERSION=$(node -p "require('./package.json').version")
        echo "version=$VERSION" >> $GITHUB_OUTPUT

Wichtig: Bei run-Steps muss immer shell angegeben werden. Das ist eine Besonderheit von Composite Actions – normale Steps erben die Shell vom Job, Composite Actions nicht.

10.2.4 Was Composite Actions nicht können

Im Vergleich zu Reusable Workflows haben Composite Actions Limitierungen:

Feature Reusable Workflow Composite Action
Mehrere Jobs
Runner-Auswahl (runs-on)
Services (z.B. Datenbanken)
Matrix-Strategien
if-Bedingungen auf Job-Ebene
Secrets als Input – (nur via Env)

Composite Actions sind einfacher, aber weniger mächtig. Sie sind ideal für Step-Sequenzen, die sich oft wiederholen, aber keine Job-Level-Features brauchen.

10.3 Die Entscheidungsmatrix

Wann verwendet man was? Die folgende Tabelle gibt Orientierung:

Szenario Empfehlung Begründung
Komplette CI/CD-Pipeline über Repos hinweg Reusable Workflow Job-Orchestrierung, Services, Matrix
Setup-Schritte (Node, Cache, Install) Composite Action Step-Sequenz, lokal oder shared
Repository-spezifische Helfer Lokale Composite Action Versioniert mit Repo, kein Ext-Dependency
Cross-Platform-Tests Reusable Workflow + Matrix Braucht runs-on-Kontrolle
Deployment-Template Reusable Workflow Environment-Schutz, Secrets
Code-Linting/Formatting Composite Action Reine Step-Sequenz

Eine Faustregel: Sobald du runs-on, services oder mehrere Jobs brauchst, nutze einen Reusable Workflow. Für alles andere sind Composite Actions einfacher.

10.4 Praktische Patterns

10.4.1 Pattern 1: Reusable Workflow mit lokalem Setup

Ein häufiges Pattern: Ein Reusable Workflow für die Pipeline-Struktur, eine lokale Composite Action für projekt-spezifische Setup-Schritte.

# Shared: org/workflows/.github/workflows/test-suite.yml
on:
  workflow_call:
    inputs:
      language:
        required: true
        type: string

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project  # lokale Action!
        with:
          language: ${{ inputs.language }}
      - run: make test

Der Reusable Workflow ist generisch, die lokale Action projekt-spezifisch. Das Beste aus beiden Welten.

10.4.2 Pattern 2: Composite Action als Wrapper

Manchmal will man eine bestehende Action mit org-spezifischen Defaults wrappen:

# .github/actions/setup-node-with-defaults/action.yml
name: Setup Node (Org Defaults)
inputs:
  node-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
      env:
        NODE_AUTH_TOKEN: ${{ github.token }}
    
    - shell: bash
      run: |
        npm config set @myorg:registry https://npm.pkg.github.com
        npm ci

Jedes Projekt kann jetzt einfach uses: ./.github/actions/setup-node-with-defaults nutzen, ohne sich um Registry-Config zu kümmern.

10.4.3 Pattern 3: Multi-Stage mit Reusable Workflows

Für komplexe Pipelines: Mehrere Reusable Workflows, die aufeinander aufbauen.

jobs:
  build:
    uses: org/workflows/.github/workflows/build.yml@v2
    with:
      artifact-name: app-${{ github.sha }}
  
  test:
    needs: build
    uses: org/workflows/.github/workflows/test.yml@v2
    with:
      artifact-name: app-${{ github.sha }}
  
  deploy-staging:
    needs: test
    if: github.ref == 'refs/heads/main'
    uses: org/workflows/.github/workflows/deploy.yml@v2
    with:
      environment: staging
      artifact-name: app-${{ github.sha }}

Jeder Stage ist ein separater Reusable Workflow. Die Orchestrierung passiert im Caller. Das ist wartbar, testbar und erlaubt flexibles Überschreiben.

10.4.4 Pattern 4: Dynamic Reusable Workflow Selection

Mit Conditional Logic kann man verschiedene Reusable Workflows je nach Kontext aufrufen:

jobs:
  determine-deployment:
    runs-on: ubuntu-latest
    outputs:
      workflow: ${{ steps.decide.outputs.workflow }}
    steps:
      - id: decide
        run: |
          if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
            echo "workflow=deploy-prod.yml" >> $GITHUB_OUTPUT
          else
            echo "workflow=deploy-preview.yml" >> $GITHUB_OUTPUT
          fi
  
  deploy:
    needs: determine-deployment
    uses: org/workflows/.github/workflows/${{ needs.determine-deployment.outputs.workflow }}@v2

Achtung: Der uses-Pfad kann keine Expression mit Runtime-Werten enthalten – nur Kontext-Variablen, die zur Parse-Zeit bekannt sind. Das obige Beispiel funktioniert nicht. Stattdessen:

jobs:
  deploy-prod:
    if: github.ref == 'refs/heads/main'
    uses: org/workflows/.github/workflows/deploy-prod.yml@v2
    
  deploy-preview:
    if: github.ref != 'refs/heads/main'
    uses: org/workflows/.github/workflows/deploy-preview.yml@v2

Conditional Jobs, nicht Dynamic Paths.

10.5 Versionierung und Lifecycle-Management

10.5.1 Semantic Versioning für Shared Actions

Shared Composite Actions und Reusable Workflows sollten nach Semantic Versioning versioniert werden. Das bedeutet:

Der Workflow:

  1. Entwickle neue Features in Feature-Branches
  2. Merge in main mit konventionellen Commits
  3. Erstelle ein Release mit Tag v1.2.3
  4. Aktualisiere den Major-Tag v1 auf denselben Commit

Nutzer können dann entscheiden:

10.5.2 Deprecation-Strategy

Wenn eine Action oder ein Reusable Workflow deprecated wird:

  1. Dokumentiere in der README und im Changelog

  2. Füge Warnings hinzu mit ::warning::-Commands:

    steps:
      - name: Deprecation Warning
        run: |
          echo "::warning::This action is deprecated. Use new-action@v2 instead."
  3. Setze ein EOL-Datum (z.B. 6 Monate)

  4. Kommuniziere über Issues, Slack, Internal Docs

10.5.3 Breaking Changes kommunizieren

Bei Breaking Changes (Major-Version-Bump):

# CHANGELOG.md
## v2.0.0 (2025-01-15)

### BREAKING CHANGES

- Removed `legacy-mode` input (deprecated since v1.5)
- Changed default `node-version` from '16' to '20'

### Migration Guide

**Before (v1.x):**
```yaml
uses: org/action@v1
with:
  legacy-mode: true
  node-version: '16'

After (v2.x):

uses: org/action@v2
with:
  node-version: '20'

Ein guter Changelog ist die halbe Miete. Die andere Hälfte: Automatisierte Tests für alle Major-Versionen.

10.6 Wartbarkeit und Skalierung

10.6.1 Monorepo für Shared Workflows

Viele Organizations zentralisieren ihre Reusable Workflows in einem Monorepo:

shared-workflows/
├── .github/
│   ├── workflows/
│   │   ├── build-node.yml
│   │   ├── build-python.yml
│   │   ├── deploy.yml
│   │   └── security-scan.yml
│   └── actions/
│       ├── setup-cloud-cli/
│       └── notify-slack/
├── docs/
└── README.md

Das ermöglicht:

10.6.2 Testing von Reusable Workflows

Reusable Workflows und Composite Actions sollten getestet werden, bevor sie released werden:

# .github/workflows/test-reusable.yml
name: Test Reusable Workflows

on:
  pull_request:
    paths:
      - '.github/workflows/**'

jobs:
  test-build-workflow:
    uses: ./.github/workflows/build-node.yml
    with:
      node-version: '20'
    secrets: inherit

Jede Änderung an einem Reusable Workflow triggert einen Test-Run. Das verhindert Breaking Changes.

10.6.3 Dependency Management

Composite Actions haben oft externe Dependencies (andere Actions). Diese sollten gepinnt werden:

# ❌ Schlecht: Unpinned
steps:
  - uses: actions/checkout@v4

# ✓ Besser: SHA-Pinned
steps:
  - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

Tools wie Dependabot können Actions automatisch updaten:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

10.7 Die richtige Abstraktion wählen

Die drei Mechanismen – Reusable Workflows, Composite Actions, lokale Actions – sind keine Konkurrenten, sondern Werkzeuge für unterschiedliche Abstraktionsebenen.

Reusable Workflows sind die Makro-Ebene: vollständige Pipeline-Templates, die Job-Orchestrierung, Runner-Auswahl und Service-Container ermöglichen. Sie sind ideal für standardisierte CI/CD-Flows über Teams und Repositories hinweg.

Composite Actions sind die Mikro-Ebene: wiederverwendbare Step-Sequenzen, die komplexe Setup-Prozeduren kapseln. Sie sind leichtgewichtiger als Reusable Workflows und können lokal oder shared sein.

Lokale Actions sind die Projekt-Ebene: repository-spezifische Helfer, die mit dem Code versionieren. Sie vermeiden externe Dependencies und sind perfekt für Projektbesonderheiten.

Die Kunst liegt in der Kombination: Ein Reusable Workflow orchestriert die Pipeline, ruft Composite Actions für Standard-Setups auf und erlaubt lokale Actions für Projekt-Spezifika. Das Ergebnis: DRY, wartbar, skalierbar – und trotzdem flexibel genug für individuelle Anforderungen.