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.
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.
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.
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/appDie 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.
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.jsDie 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.
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: 0Umgekehrt lohnt sich bei hoch-redundanten Textdaten (Logs, Source-Code) Level 9:
- uses: actions/upload-artifact@v4
with:
name: logs
path: logs/*.log
compression-level: 9Die 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 |
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: 1Der 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.
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.
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 ciDer Cache-Key ist der Dreh- und Angelpunkt. Er muss zwei Anforderungen erfüllen:
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:
runner.os: Betriebssystem (Linux/Windows/macOS)node: Literal zur Unterscheidung verschiedener
Cachespackage-lock.jsonÄ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).
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:
keyrestore-key:
npm-${{ runner.os }}-*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 ciEin 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.
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.
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 testUnter 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.
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.
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).
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.
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/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 ciFü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.
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: trueHidden 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: trueCache-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: trueLookup-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.shEin 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.