25 Das Actions-Ökosystem – Wiederverwendbarkeit und Sicherheit

Workflows wachsen. Was mit 20 Zeilen YAML beginnt, wird schnell zu 200 Zeilen. Und dann existiert derselbe Workflow in zehn Repositories – alle leicht unterschiedlich, alle manuell gepflegt. GitHub Actions bietet zwei mächtige Mechanismen für Wiederverwendbarkeit: Reusable Workflows und Custom Actions.

Dieses Kapitel zeigt, wie man wiederverwendbare Komponenten baut, wann man welchen Ansatz wählt und wie man Third-Party-Actions sicher einsetzt. Am Ende haben wir eine zentrale Workflow-Bibliothek für badge-gen und verstehen, warum SHA-Pinning keine optionale Sicherheitsmaßnahme ist.

25.1 Reusable Workflows – ganze Workflows wiederverwenden

25.1.1 Das Konzept

Ein Reusable Workflow ist ein vollständiger Workflow, der von anderen Workflows aufgerufen werden kann. Der aufrufende Workflow heißt Caller, der aufgerufene Called Workflow.

Kernidee: Statt denselben CI-Workflow in 20 Repositories zu kopieren, definiert man ihn einmal in einem zentralen Repository und ruft ihn von überall auf.

25.1.2 Einen Reusable Workflow erstellen

Ein Workflow wird wiederverwendbar durch workflow_call im on-Block:

# .github/workflows/ci-python.yml (im shared-workflows Repository)
name: Python CI

on:
  workflow_call:
    inputs:
      python-version:
        required: false
        type: string
        default: '3.12'
      run-lint:
        required: false
        type: boolean
        default: true
    secrets:
      codecov-token:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ inputs.python-version }}
          cache: 'pip'
      
      - name: Install dependencies
        run: pip install -e .[dev]
      
      - name: Lint
        if: ${{ inputs.run-lint }}
        run: |
          ruff check .
          mypy src/
      
      - name: Test
        run: pytest --cov=src
      
      - name: Upload coverage
        if: ${{ secrets.codecov-token != '' }}
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.codecov-token }}

Wichtige Elemente:

25.1.3 Einen Reusable Workflow aufrufen

# .github/workflows/ci.yml (im badge-gen Repository)
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    uses: my-org/shared-workflows/.github/workflows/ci-python.yml@main
    with:
      python-version: '3.12'
      run-lint: true
    secrets:
      codecov-token: ${{ secrets.CODECOV_TOKEN }}

Syntax: uses: owner/repo/.github/workflows/workflow.yml@ref

Referenz-Optionen:

25.1.4 Inputs und Secrets

Input-Typen:

Typ Beschreibung Beispiel
string Text '3.12', 'production'
number Zahl 5, 100
boolean Wahrheitswert true, false

Secrets durchreichen:

# Einzelne Secrets
secrets:
  deploy-key: ${{ secrets.DEPLOY_KEY }}
  api-token: ${{ secrets.API_TOKEN }}

# Alle Secrets (Convenience)
secrets: inherit

secrets: inherit reicht alle Secrets des Callers durch – praktisch, aber weniger explizit.

25.1.5 Outputs zurückgeben

Reusable Workflows können Daten an den Caller zurückgeben:

# Called Workflow
on:
  workflow_call:
    outputs:
      version:
        description: "Calculated version"
        value: ${{ jobs.build.outputs.version }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - id: version
        run: echo "version=1.2.3" >> $GITHUB_OUTPUT
# Caller Workflow
jobs:
  build:
    uses: my-org/shared-workflows/.github/workflows/build.yml@main
  
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ needs.build.outputs.version }}"

25.1.6 Limitierungen

Limit Wert
Verschachtelungstiefe 10 Ebenen
Unique Reusable Workflows pro Datei 50
Secrets-Vererbung Nur an direkt aufgerufene Workflows

Wichtig: Environment-Variablen (env:) werden nicht an Called Workflows vererbt. Daten müssen explizit als inputs übergeben werden.

25.1.7 Wann Reusable Workflows nutzen?

✅ Gut geeignet für:

❌ Weniger geeignet für:

25.2 Custom Actions – eigene Bausteine erstellen

25.2.1 Die drei Action-Typen

Typ Läuft in Geschwindigkeit Plattform Use-Case
Composite Runner direkt ⚡ Schnell Alle Steps bündeln
JavaScript Node.js Runtime ⚡ Schnell Alle Komplexe Logik, API-Calls
Docker Container 🐢 Langsamer Nur Linux Spezifische Dependencies

25.2.2 Composite Actions

Eine Composite Action bündelt mehrere Steps in eine wiederverwendbare Einheit.

Struktur:

.github/
  actions/
    setup-python-env/
      action.yml

action.yml:

name: 'Setup Python Environment'
description: 'Install Python, dependencies and tools'

inputs:
  python-version:
    description: 'Python version to use'
    required: false
    default: '3.12'
  install-dev:
    description: 'Install dev dependencies'
    required: false
    default: 'true'

outputs:
  cache-hit:
    description: 'Whether cache was hit'
    value: ${{ steps.cache.outputs.cache-hit }}

runs:
  using: 'composite'
  steps:
    - name: Setup Python
      uses: actions/setup-python@v5
      with:
        python-version: ${{ inputs.python-version }}
    
    - name: Cache pip
      id: cache
      uses: actions/cache@v4
      with:
        path: ~/.cache/pip
        key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
    
    - name: Install dependencies
      shell: bash
      run: |
        pip install --upgrade pip
        if [[ "${{ inputs.install-dev }}" == "true" ]]; then
          pip install -e .[dev]
        else
          pip install -e .
        fi

Verwendung (lokal):

steps:
  - uses: actions/checkout@v4
  
  - name: Setup environment
    uses: ./.github/actions/setup-python-env
    with:
      python-version: '3.11'
      install-dev: 'true'

Verwendung (remote):

steps:
  - uses: my-org/shared-actions/setup-python-env@v1
    with:
      python-version: '3.11'

25.2.3 Composite Action: Wichtige Details

1. Shell muss explizit angegeben werden:

- run: echo "Hello"
  shell: bash  # Pflicht bei Composite Actions!

2. Inputs sind immer Strings:

# ❌ Funktioniert nicht wie erwartet
if: ${{ inputs.install-dev }}  # Immer truthy weil String

# ✅ Korrekt
if: ${{ inputs.install-dev == 'true' }}

3. Outputs müssen gemapped werden:

outputs:
  result:
    value: ${{ steps.compute.outputs.result }}  # Mapping erforderlich

25.2.4 JavaScript Actions

Für komplexere Logik: JavaScript Actions mit dem GitHub Actions Toolkit.

Struktur:

my-action/
  action.yml
  index.js
  package.json
  dist/
    index.js  # Kompiliertes Bundle

action.yml:

name: 'Badge Generator'
description: 'Generate SVG badges from metrics'

inputs:
  metric-name:
    description: 'Name of the metric'
    required: true
  value:
    description: 'Value to display'
    required: true
  color:
    description: 'Badge color'
    required: false
    default: 'blue'

outputs:
  badge-path:
    description: 'Path to generated badge'

runs:
  using: 'node20'
  main: 'dist/index.js'

index.js:

const core = require('@actions/core');
const github = require('@actions/github');
const fs = require('fs');

async function run() {
  try {
    // Inputs lesen
    const metricName = core.getInput('metric-name');
    const value = core.getInput('value');
    const color = core.getInput('color');
    
    // Badge generieren (vereinfacht)
    const svg = generateBadge(metricName, value, color);
    
    // Datei schreiben
    const badgePath = `badges/${metricName}.svg`;
    fs.writeFileSync(badgePath, svg);
    
    // Output setzen
    core.setOutput('badge-path', badgePath);
    
    // Log
    core.info(`Badge generated: ${badgePath}`);
    
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();

Kompilierung:

npm install @vercel/ncc -g
ncc build index.js -o dist

Das dist/index.js enthält dann alle Dependencies gebundelt.

25.2.5 Docker Actions

Wenn spezifische Tools oder OS-Dependencies nötig sind: Docker Actions.

action.yml:

name: 'Python Linter'
description: 'Run Python linting with all tools'

inputs:
  path:
    description: 'Path to lint'
    required: false
    default: '.'

runs:
  using: 'docker'
  image: 'Dockerfile'
  args:
    - ${{ inputs.path }}

Dockerfile:

FROM python:3.12-slim

RUN pip install ruff mypy black

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

entrypoint.sh:

#!/bin/bash
set -e

PATH_TO_LINT=${1:-.}

echo "Running ruff..."
ruff check "$PATH_TO_LINT"

echo "Running mypy..."
mypy "$PATH_TO_LINT"

echo "Running black..."
black --check "$PATH_TO_LINT"

Trade-off: Docker Actions sind langsamer (Image muss gebaut/gepullt werden), aber bieten vollständige Kontrolle über die Umgebung.

25.2.6 Vergleich: Reusable Workflows vs. Composite Actions

Aspekt Reusable Workflow Composite Action
Granularität Gesamter Workflow (Jobs) Einzelne Steps
Runner-Auswahl Ja (pro Job) Nein (erbt vom Caller)
Logging Separate Jobs sichtbar Ein Step im Caller
Secrets-Zugriff Explizit durchgereicht Als Inputs/Env
Environment-Vars Nicht vererbt Vererbt
Use-Case Komplette Pipelines Wiederverwendbare Step-Bundles

Faustregel:

25.3 Praxis: Zentrale Workflow-Bibliothek

25.3.1 Repository-Struktur

my-org/shared-workflows/
├── .github/
│   └── workflows/
│       ├── ci-python.yml
│       ├── ci-node.yml
│       ├── release.yml
│       └── deploy-pages.yml
├── actions/
│   ├── setup-python-env/
│   │   └── action.yml
│   ├── generate-changelog/
│   │   └── action.yml
│   └── notify-slack/
│       └── action.yml
└── README.md

25.3.2 Beispiel: CI-Workflow für Python-Projekte

# .github/workflows/ci-python.yml
name: Python CI

on:
  workflow_call:
    inputs:
      python-version:
        type: string
        default: '3.12'
      os:
        type: string
        default: 'ubuntu-latest'
      run-lint:
        type: boolean
        default: true
      run-security:
        type: boolean
        default: true
      coverage-threshold:
        type: number
        default: 80
    secrets:
      codecov-token:
        required: false
    outputs:
      coverage:
        description: 'Test coverage percentage'
        value: ${{ jobs.test.outputs.coverage }}

jobs:
  lint:
    if: ${{ inputs.run-lint }}
    runs-on: ${{ inputs.os }}
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ inputs.python-version }}
          cache: 'pip'
      
      - name: Install linters
        run: pip install ruff mypy
      
      - name: Run ruff
        run: ruff check .
      
      - name: Run mypy
        run: mypy src/ --ignore-missing-imports

  security:
    if: ${{ inputs.run-security }}
    runs-on: ${{ inputs.os }}
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ inputs.python-version }}
      
      - name: Install security tools
        run: pip install bandit safety
      
      - name: Run Bandit
        run: bandit -r src/
      
      - name: Check dependencies
        run: safety check

  test:
    runs-on: ${{ inputs.os }}
    outputs:
      coverage: ${{ steps.coverage.outputs.percentage }}
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ inputs.python-version }}
          cache: 'pip'
      
      - name: Install dependencies
        run: pip install -e .[dev]
      
      - name: Run tests
        run: pytest --cov=src --cov-report=xml
      
      - name: Extract coverage
        id: coverage
        run: |
          COVERAGE=$(python -c "import xml.etree.ElementTree as ET; print(int(float(ET.parse('coverage.xml').getroot().get('line-rate')) * 100))")
          echo "percentage=$COVERAGE" >> $GITHUB_OUTPUT
      
      - name: Check coverage threshold
        run: |
          if [[ ${{ steps.coverage.outputs.percentage }} -lt ${{ inputs.coverage-threshold }} ]]; then
            echo "Coverage ${{ steps.coverage.outputs.percentage }}% is below threshold ${{ inputs.coverage-threshold }}%"
            exit 1
          fi
      
      - name: Upload to Codecov
        if: ${{ secrets.codecov-token != '' }}
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.codecov-token }}

25.3.3 Verwendung in badge-gen

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

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  ci:
    uses: my-org/shared-workflows/.github/workflows/ci-python.yml@v1
    with:
      python-version: '3.12'
      run-lint: true
      run-security: true
      coverage-threshold: 85
    secrets:
      codecov-token: ${{ secrets.CODECOV_TOKEN }}
  
  report:
    needs: ci
    runs-on: ubuntu-latest
    steps:
      - run: echo "Coverage: ${{ needs.ci.outputs.coverage }}%"

Der gesamte CI-Prozess (Lint + Security + Test) in 10 Zeilen YAML.

25.4 Security: Third-Party Actions absichern

25.4.1 Das Risiko

Third-Party Actions haben vollen Zugriff auf:

Ein kompromittiertes Action-Repository kann Secrets exfiltrieren, Code manipulieren oder Supply-Chain-Angriffe durchführen.

Reales Beispiel: Im März 2025 wurde tj-actions/changed-files kompromittiert. Alle Tags wurden auf einen Commit mit Malware umgebogen.

25.4.2 SHA-Pinning – die einzige sichere Methode

Tags können geändert werden. Branches sowieso. Der einzige immutable Identifier ist der Commit SHA.

Unsicher:

- uses: actions/checkout@v4  # Tag kann verschoben werden
- uses: actions/checkout@main  # Branch ändert sich ständig

Sicher:

- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

25.4.3 SHA finden

Methode 1: GitHub UI

  1. Action-Repository öffnen (z.B. github.com/actions/checkout)
  2. Releases/Tags anschauen
  3. Auf den Tag klicken → Commit SHA kopieren

Methode 2: CLI

git ls-remote --tags https://github.com/actions/checkout.git | grep v4.1.1
# Output: b4ffde65f46336ab88eb53be808477a3936bae11  refs/tags/v4.1.1

25.4.4 Best Practice: SHA + Kommentar

steps:

  - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
  - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c  # v5.0.0
  - uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d  # v4.0.1

Warum der Kommentar? Menschen können SHA nicht lesen. Der Kommentar zeigt die Version.

25.4.5 Dependabot für automatische Updates

SHA-Pinning bedeutet kein automatisches Update. Dependabot hilft:

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

Was passiert:

  1. Dependabot prüft wöchentlich auf neue Action-Versionen
  2. Erstellt PR mit SHA-Update
  3. PR kann reviewed und gemerged werden

Beispiel-PR:

ci: bump actions/checkout from v4.1.1 to v4.1.2

Updates actions/checkout from b4ffde65... to a12b4567...

25.4.6 Weitere Security-Maßnahmen

1. Permissions minimieren:

permissions:
  contents: read  # Nur was nötig ist

2. Verified Creator bevorzugen:

Im Marketplace haben Actions von verifizierten Erstellern ein Badge. GitHub hat die Identität überprüft.

3. Action-Code reviewen:

Bei kritischen Actions (die Secrets sehen): Code auf GitHub anschauen. Was macht die Action wirklich?

4. Fork als Absicherung:

Für maximale Kontrolle: Action in eigene Organisation forken und von dort referenzieren.

- uses: my-org/checkout@v4  # Geforktes Repository

Nachteil: Manuelles Sync bei Updates nötig.

25.4.7 Automatisches SHA-Pinning

pinact – CLI-Tool zum automatischen Pinning:

# Installation
go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest

# Workflow pinnen
pinact run

Wandelt actions/checkout@v4 automatisch in actions/checkout@b4ffde65... # v4.1.1 um.

25.5 Edge-Cases und Troubleshooting

25.5.1 Problem: Reusable Workflow findet Secrets nicht

Symptom: secrets.MY_SECRET ist leer im Called Workflow.

Ursache: Secrets werden nicht automatisch vererbt.

Lösung:

# Explizit
secrets:
  MY_SECRET: ${{ secrets.MY_SECRET }}

# Oder alle
secrets: inherit

25.5.2 Problem: Composite Action kann keine anderen Actions aufrufen

Symptom: uses: in Composite Action funktioniert nicht.

Ursache: Ältere GitHub Enterprise Versionen unterstützen das nicht.

Lösung: GitHub Enterprise auf Version 3.4+ aktualisieren oder nur run: Steps verwenden.

25.5.3 Problem: Matrix in Reusable Workflow

Symptom: Caller will Matrix an Called Workflow übergeben.

Realität: Nicht direkt möglich. Matrix muss im Called Workflow definiert sein.

Workaround: Matrix als JSON-Input übergeben und mit fromJSON() parsen:

# Caller
jobs:
  ci:
    uses: my-org/shared/.github/workflows/ci.yml@main
    with:
      matrix-json: '{"python": ["3.11", "3.12"], "os": ["ubuntu-latest"]}'

# Called
on:
  workflow_call:
    inputs:
      matrix-json:
        type: string
        required: true

jobs:
  test:
    strategy:
      matrix: ${{ fromJSON(inputs.matrix-json) }}

25.5.4 Problem: Lokale Action im Reusable Workflow

Symptom: uses: ./my-action im Reusable Workflow findet Action nicht.

Ursache: Relative Pfade beziehen sich auf das Caller-Repository, nicht das Workflow-Repository.

Lösung: Action separat referenzieren:

uses: my-org/shared-workflows/my-action@main

25.5.5 Problem: GITHUB_TOKEN Permissions in Nested Workflows

Regel: Nested Workflows können Permissions nur einschränken, nicht erweitern.

Workflow A (contents: write) 
  → calls B (contents: write) ✅
  → calls B (contents: read) ✅
  → calls B (packages: write) ❌ (A hat packages nicht)

25.6 Architektur-Empfehlungen

25.6.1 Kleine Organisation (1-5 Repos)

Lokale Composite Actions:

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

Actions im selben Repository, keine externe Abhängigkeit.

25.6.2 Mittlere Organisation (5-20 Repos)

Zentrales shared-workflows Repository:

org/shared-workflows/     # Zentrale Workflows + Actions
org/app-1/               # uses: org/shared-workflows/...
org/app-2/
org/app-3/

Ein Team pflegt die Workflows, alle nutzen sie.

25.6.3 Große Organisation (20+ Repos)

Hierarchische Struktur:

org/platform-workflows/   # Basis-Workflows (Security, Compliance)
org/team-a-workflows/    # Team-spezifische Erweiterungen
org/team-b-workflows/
org/app-1/               # Kombiniert Platform + Team Workflows

Platform-Team definiert Mindeststandards, Teams erweitern.

25.6.4 Versioning-Strategie für Shared Workflows

1. Semantic Versioning:

uses: my-org/shared/.github/workflows/ci.yml@v1.0.0

2. Major-Version-Tags (empfohlen):

uses: my-org/shared/.github/workflows/ci.yml@v1

Tag v1 zeigt immer auf den neuesten v1.x.x Release. Breaking Changes → v2.

3. Maintenance:

# Nach Release v1.2.0
git tag -f v1 v1.2.0  # v1 auf v1.2.0 verschieben
git push origin v1 --force

Workflow:

  1. Feature-Branch für Änderungen
  2. PR mit Review
  3. Merge + neuer Tag (v1.2.0)
  4. Major-Tag updaten (v1 → v1.2.0)