9 Artefakte und Caching

Die Wiederverwendung von Daten ist einer der wichtigsten Hebel zur Performance-Optimierung in CI/CD-Pipelines. GitHub Actions bietet dafür zwei komplementäre Mechanismen: Artefakte für den Austausch von Build-Outputs zwischen Jobs und die langfristige Speicherung nach Workflow-Abschluss, sowie Caching für die wiederholte Nutzung unveränderlicher Dependencies über mehrere Workflow-Runs hinweg.

Der Unterschied ist subtil, aber entscheidend: Artefakte sind die Ergebnisse eines Builds – das kompilierte Binary, der Testbericht, das Docker-Image. Caching hingegen beschleunigt den Weg zum Ergebnis, indem es teure Download- und Installationsschritte überspringt. Man könnte sagen: Artefakte sind Output, Caching ist Input.

9.1 Artefakte: Output zwischen Jobs weitergeben

9.1.1 Die Anatomie eines Artefakts

Ein Artefakt in GitHub Actions ist ein benanntes ZIP-Archiv, das während eines Workflow-Runs erstellt wird. Die upload-artifact@v4 Action packt die angegebenen Dateien und Verzeichnisse in ein immutables Archiv, das anschließend in anderen Jobs desselben Workflows heruntergeladen oder nach Workflow-Abschluss manuell aus der UI bezogen werden kann.

Die grundlegende Verwendung ist denkbar einfach:

- name: Build application
  run: npm run build

- name: Upload build artifacts
  uses: actions/upload-artifact@v4
  with:
    name: dist-files
    path: dist/

GitHub speichert das Artefakt für 90 Tage (Standard) und stellt es über die Workflow-UI, die REST API und über download-artifact bereit. Beachtenswert ist, dass v4 der Action eine fundamentale Änderung gegenüber v3 eingeführt hat: Artefakte sind nun unveränderlich. Ein einmal hochgeladenes Artefakt mit einem bestimmten Namen kann nicht mehr erweitert oder modifiziert werden.

9.1.2 Immutability und ihre Konsequenzen

In v3 war es möglich, mehrfach zum selben Artefaktnamen hochzuladen – die Dateien wurden zusammengeführt. Das führte jedoch zu schwer nachvollziehbaren Zuständen, insbesondere bei Matrix-Jobs:

# Anti-Pattern in v4!
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    
steps:
  - run: ./build.sh
  - uses: actions/upload-artifact@v4
    with:
      name: binaries  # Beide Jobs verwenden denselben Namen
      path: output/

Dieser Code würde fehlschlagen, sobald der zweite Job versucht hochzuladen. Die Lösung ist, eindeutige Namen zu verwenden:

- uses: actions/upload-artifact@v4
  with:
    name: binaries-${{ matrix.os }}
    path: output/

Das Resultat sind zwei getrennte Artefakte: binaries-ubuntu-latest und binaries-macos-latest. Möchte man sie später zusammenführen, gibt es seit v4 die upload-artifact/merge Sub-Action, die mehrere Artefakte zu einem vereint.

9.1.3 Datenaustausch zwischen Jobs

Der klassische Anwendungsfall: Job A baut, Job B testet den Build, Job C deployed ihn. Artefakte sind das Transportmedium:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Compile
        run: make build
      - uses: actions/upload-artifact@v4
        with:
          name: compiled-app
          path: build/app

  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: compiled-app
          path: ./app
      - name: Run tests
        run: ./test.sh ./app/app

Die download-artifact Action lädt standardmäßig ins Arbeitsverzeichnis herunter. Mit path kann ein anderes Zielverzeichnis angegeben werden. Ohne Angabe eines name werden alle Artefakte des Workflows heruntergeladen, jedes in ein eigenes Unterverzeichnis.

9.1.4 Glob-Patterns und Excludes

Die path-Angabe unterstützt Wildcards und Ausschlüsse, was besonders nützlich ist, um temporäre Dateien oder Secrets aus dem Upload auszuschließen:

- uses: actions/upload-artifact@v4
  with:
    name: production-bundle
    path: |
      dist/**/*
      !dist/**/*.map
      !dist/**/*.test.js

Die Glob-Syntax folgt der @actions/glob Bibliothek. Wichtig: Nach dem ersten Wildcard wird die Pfadhierarchie abgeflacht. Ein Pattern wie src/**/output/*.txt würde alle .txt-Dateien aus verschiedenen output-Verzeichnissen in die Struktur output/*.txt im Artefakt packen.

9.1.5 Compression-Level tunen

Standardmäßig verwendet upload-artifact Kompressionsstufe 6 (wie gzip). Für große, bereits komprimierte Dateien (Videos, Bilder, kompilierte Binaries) kann Level 0 die Upload-Zeit massiv reduzieren:

- uses: actions/upload-artifact@v4
  with:
    name: large-dataset
    path: data/dataset.bin
    compression-level: 0

Umgekehrt lohnt sich bei hoch-redundanten Textdaten (Logs, Source-Code) Level 9:

- uses: actions/upload-artifact@v4
  with:
    name: logs
    path: logs/*.log
    compression-level: 9

Die folgende Tabelle gibt eine Orientierung:

Datentyp Empfohlene Stufe Begründung
Binaries, kompilierte Libs 0-1 Bereits komprimiert oder binär-random
Bilder, Videos 0 Enthalten eigene Kompression
Logs, Plaintext 8-9 Hohe Redundanz, gute Kompressibilität
Source-Code, HTML/CSS 6-8 Moderate Redundanz, Standard ausreichend
ZIP/TAR-Archive 0 Bereits gepackt

9.1.6 Retention und Lifecycle

Artefakte belegen Storage und verursachen Kosten. Die Retention-Policy legt fest, wie lange ein Artefakt aufbewahrt wird:

- uses: actions/upload-artifact@v4
  with:
    name: temporary-build
    path: build/
    retention-days: 1

Der Wert kann zwischen 1 und 90 Tagen (public Repos) bzw. 400 Tagen (private Repos) liegen. Wird nichts angegeben, gilt die Repository-Standardeinstellung (typischerweise 90 Tage). Für kurzlebige Debug-Artifacts oder Matrix-Outputs, die nur im selben Workflow gebraucht werden, ist retention-days: 1 oft ausreichend.

Ein weiterer Mechanismus: Das overwrite-Flag. Setzt man es auf true, wird ein existierendes Artefakt mit gleichem Namen überschrieben. Aber Vorsicht: Das neue Artefakt erhält eine neue ID, alle Referenzen auf das alte sind ungültig.

9.1.7 Artifact-Outputs und Traceability

Seit v4 gibt upload-artifact mehrere Outputs zurück:

- name: Upload
  id: upload-step
  uses: actions/upload-artifact@v4
  with:
    name: my-artifact
    path: output/

- name: Log artifact details
  run: |
    echo "ID: ${{ steps.upload-step.outputs.artifact-id }}"
    echo "URL: ${{ steps.upload-step.outputs.artifact-url }}"
    echo "Digest: ${{ steps.upload-step.outputs.artifact-digest }}"

Der artifact-digest ist ein SHA-256-Hash des Inhalts. download-artifact verifiziert diesen Hash beim Download automatisch – eine zusätzliche Integritätsprüfung, die Man-in-the-Middle-Szenarien erschwert.

9.2 Dependency Caching: Dependencies nicht zweimal laden

9.2.1 Warum Caching?

Ein typischer Node.js-Build lädt bei npm install hunderte Megabyte Dependencies aus der Registry. Bei Java mit Maven sind es oft Gigabytes. Das passiert bei jedem Workflow-Run – es sei denn, man cached die node_modules oder das Maven Local Repository.

Das Prinzip: Nach dem ersten Download werden die Dependencies in einem GitHub-gehosteten Storage abgelegt, identifiziert durch einen Cache-Key. Folgende Runs prüfen, ob ein Cache mit passendem Key existiert, und stellen ihn wieder her. Der Workflow überspringt den Download-Step.

- name: Cache Node modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    
- name: Install dependencies
  run: npm ci

9.2.2 Cache-Keys: Die Anatomie

Der Cache-Key ist der Dreh- und Angelpunkt. Er muss zwei Anforderungen erfüllen:

  1. Eindeutigkeit: Ändern sich die Dependencies, muss sich der Key ändern
  2. Stabilität: Solange Dependencies unverändert sind, bleibt der Key gleich

Die Funktion hashFiles() ist das Werkzeug der Wahl. Sie berechnet einen SHA-256-Hash über die angegebenen Dateien:

key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

Dieser Key enthält drei Komponenten:

Ändert sich die Lockfile, ändert sich der Hash, und ein neuer Cache wird erstellt. Das Betriebssystem ist wichtig, weil Caches nicht zwischen OS-Typen kompatibel sind (unterschiedliche Binaries, Pfade).

9.2.3 Restore-Keys: Fuzzy Matching

Was passiert, wenn die package-lock.json sich ändert? Ohne weitere Konfiguration wäre der Cache ungültig, und alle Dependencies müssten neu geladen werden. Restore-Keys bieten einen Fallback-Mechanismus:

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-
      npm-

GitHub versucht Keys in dieser Reihenfolge:

  1. Exakter Match auf key
  2. Prefix-Match auf dem ersten restore-key: npm-${{ runner.os }}-*
  3. Prefix-Match auf dem zweiten restore-key: npm-*

Findet sich z.B. ein Cache npm-Linux-abc123 (alter Hash), wird dieser restored. npm ci erkennt, dass Dependencies fehlen oder veraltet sind, und lädt nur die Differenz. Das ist deutlich schneller als ein kompletter Download.

Der Output cache-hit zeigt an, ob ein exakter Match gefunden wurde:

- name: Cache dependencies
  id: cache
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

- name: Install if cache miss
  if: steps.cache.outputs.cache-hit != 'true'
  run: npm ci

9.2.4 Cache-Limits und Eviction

Ein Repository kann bis zu 10 GB an Caches speichern. Wird dieses Limit überschritten, löscht GitHub automatisch die am längsten nicht genutzten Caches (LRU: Least Recently Used). Zusätzlich werden Caches nach 7 Tagen Inaktivität gelöscht.

Ein praktisches Beispiel: Ein Projekt mit mehreren Branches, jeder mit eigenen Dependencies. Jeder Branch erzeugt einen eigenen Cache. Schnell ist das Limit erreicht. Die Lösung: Caches zwischen Branches teilen, indem der Key nicht den Branch-Namen enthält, oder die Retention durch strategisches restore-keys-Design optimieren.

9.2.5 Granulares Caching mit restore und save

Die kombinierte cache Action restored und speichert in einem Schritt. Für feinere Kontrolle gibt es cache/restore und cache/save:

steps:
  - uses: actions/cache/restore@v4
    id: cache
    with:
      path: ~/.m2/repository
      key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
      
  - name: Build
    run: mvn package
    
  - name: Run tests
    run: mvn test
    
  - uses: actions/cache/save@v4
    if: always()
    with:
      path: ~/.m2/repository
      key: ${{ steps.cache.outputs.cache-primary-key }}

Der Vorteil: Der Cache wird erst am Ende gespeichert, auch wenn der Build fehlschlägt (if: always()). Das vermeidet inkonsistente Cache-Zustände bei abgebrochenen Runs.

9.2.6 Setup-Actions mit integriertem Caching

Viele Setup-Actions bringen natives Caching mit. setup-node beispielsweise:

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'
    
- run: npm ci
- run: npm test

Unter der Haube verwendet setup-node die cache Action mit einem vordefinierten Key-Schema. Das ist bequem und für Standard-Setups ausreichend. Der Cache-Key basiert auf package-lock.json (bei npm) bzw. yarn.lock (bei Yarn) oder pnpm-lock.yaml (bei pnpm).

Für komplexere Szenarien – etwa Monorepos mit mehreren Lockfiles – greift man zur manuellen cache Action:

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      **/node_modules
    key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

Hier werden sowohl der globale npm-Cache als auch alle node_modules-Verzeichnisse gecacht. Das beschleunigt den Build weiter, da nicht einmal das Linken notwendig ist.

9.3 Artefakte vs. Caching: Die Entscheidungsmatrix

Wann verwendet man was? Die folgende Tabelle zeigt typische Szenarien:

Szenario Artefakte Caching
Kompiliertes Binary zwischen Jobs teilen
npm/Maven-Dependencies zwischen Runs wiederverwenden
Testberichte nach Workflow-Abschluss bereitstellen
Docker-Layer zwischen Builds teilen
Production-Bundle für Deployment bereitstellen
Compiler-Cache (ccache, sccache)
Code-Coverage-Reports

Eine Faustregel:

Konkret: Das kompilierte Binary aus einem PR-Build ist ein Artefakt. Die node_modules, die sowohl im PR als auch im Main-Branch-Build verwendet werden, sind Cache-Kandidaten.

9.4 Performance-Patterns für die Praxis

9.4.1 Pattern 1: Layered Caching für Monorepos

In einem Monorepo mit mehreren Packages möchte man nicht bei Änderung eines Packages alle Caches invalidieren:

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-global-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    
- uses: actions/cache@v4
  with:
    path: packages/*/node_modules
    key: npm-packages-${{ runner.os }}-${{ hashFiles('packages/*/package-lock.json') }}

Zwei separate Caches: einer für den globalen npm-Cache (ändert sich selten), einer für Package-spezifische node_modules (ändert sich bei Package-Updates).

9.4.2 Pattern 2: Matrix-optimiertes Caching

Bei Matrix-Builds mit verschiedenen Versionen (Node 18, 20, 22) sollte jede Version einen eigenen Cache erhalten:

strategy:
  matrix:
    node: [18, 20, 22]
    
steps:
  - uses: actions/cache@v4
    with:
      path: ~/.npm
      key: npm-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}

Der Key enthält die Node-Version, sodass nicht verschiedene Versionen denselben Cache überschreiben.

9.4.3 Pattern 3: Conditional Artifact Upload

Große Artefakte nur bei Bedarf hochladen, z.B. nur bei Fehlschlag:

- name: Run tests
  id: tests
  run: npm test
  continue-on-error: true
  
- name: Upload logs on failure
  if: steps.tests.outcome == 'failure'
  uses: actions/upload-artifact@v4
  with:
    name: test-logs
    path: logs/

Oder nur für bestimmte Branches:

- uses: actions/upload-artifact@v4
  if: github.ref == 'refs/heads/main'
  with:
    name: production-bundle
    path: dist/

9.4.4 Pattern 4: Cache-Warming für selbst-gehostete Runner

Bei selbst-gehosteten Runnern kann man Caches “vorwärmen”, um den ersten Build zu beschleunigen:

# Separater Workflow, der nachts läuft
on:
  schedule:
    - cron: '0 2 * * *'
    
jobs:
  warm-cache:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
      - run: npm ci

9.4.5 Pattern 5: Cache-Keys mit Zeitkomponente

Für Caches, die regelmäßig invalidiert werden sollen (z.B. täglich), kann man eine Zeitkomponente einbauen:

- name: Get current date
  id: date
  run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
  
- uses: actions/cache@v4
  with:
    path: ~/some-cache
    key: cache-${{ steps.date.outputs.date }}-${{ hashFiles('**/deps.lock') }}
    restore-keys: cache-

Jeden Tag entsteht ein neuer Key, alte Caches werden nach 7 Tagen automatisch gelöscht.

9.5 Praktische Hinweise und Stolpersteine

Cross-Platform-Caching ist standardmäßig deaktiviert. Ein auf Linux erstellter Cache kann nicht auf Windows restored werden. Der enableCrossOsArchive-Parameter aktiviert dies, aber mit Performance-Einbußen:

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('**/package-lock.json') }}
    enableCrossOsArchive: true

Hidden Files werden seit v4.4 von upload-artifact standardmäßig ignoriert. Das schützt vor versehentlichem Upload von .env-Dateien. Wenn Hidden Files benötigt werden:

- uses: actions/upload-artifact@v4
  with:
    name: config
    path: config/
    include-hidden-files: true

Cache-Miss-Strategien: Wenn ein bestimmter Cache zwingend benötigt wird, kann man mit fail-on-cache-miss den Job abbrechen:

- uses: actions/cache@v4
  with:
    path: prebuild-artifacts/
    key: prebuild-${{ github.sha }}
    fail-on-cache-miss: true

Lookup-only-Modus: Manchmal will man nur prüfen, ob ein Cache existiert, ohne ihn herunterzuladen:

- uses: actions/cache@v4
  id: cache-check
  with:
    path: large-dataset/
    key: dataset-v1
    lookup-only: true
    
- name: Download dataset if not cached
  if: steps.cache-check.outputs.cache-hit != 'true'
  run: ./download-dataset.sh

Ein letzter Hinweis zu Artefakt-Limits: Pro Job können maximal 500 Artefakte erstellt werden. Bei Matrix-Jobs mit vielen Kombinationen ist das schnell erreicht. Die Lösung: Mehrere Outputs in einem Artefakt bündeln oder die upload-artifact/merge Action verwenden.

Die Kombination von Artefakten und Caching ist mächtig, erfordert aber strategisches Denken. Ein gut konfiguriertes Caching-Setup kann Workflow-Laufzeiten um 50-80% reduzieren – vorausgesetzt, die Cache-Keys sind stabil und die Eviction-Policy wird verstanden.