WordPress-Plugin mit GitLab CI nach SVN deployen: mein Release läuft auf einen Klick

Ich habe seit Jahren ein Plugin im offiziellen WordPress-Verzeichnis: portrait-archiv-shop, den Shop-Teil meiner Bildagentur portrait-archiv.com. Aktuell Version 4.4.0. Und ich habe ziemlich lange gebraucht, um mein WordPress-Plugin mit GitLab CI nach SVN zu deployen, statt es jedes Mal von Hand hochzuschieben. Schwierig war das nie. Ich habe es geschoben, so wie man alles schiebt, was man ja irgendwie hinkriegt, wenn man sich eine Stunde hinsetzt und konzentriert.

Nur: Ein Release, für das man sich konzentrieren muss, geht irgendwann schief.

Warum mich das SVN von wordpress.org so genervt hat

Mein Entwicklungsprozess sieht bei jedem Projekt gleich aus. Alles liegt in meinem GitLab, feature/* zweigt von integration ab, integration von master. Die CI lintet, testet, baut ein Paket. Wenn das Paket grün ist, ist es das Ergebnis, mit dem ich weiterarbeite. Das ist mein Takt, seit Jahren, über alle Projekte hinweg.

wordpress.org spielt bei dem Spiel nicht mit. Das Plugin-Verzeichnis läuft auf Subversion, mit der klassischen Struktur aus trunk, tags und assets. Kein Git, keine Pull Requests, kein Tag-Objekt. Ein Release ist dort ein Verzeichnis, das du kopierst. Und assets ist nicht mal Teil des Plugins, da liegen Banner, Icon und Screenshots für die Store-Seite.

Alte Werkzeuge für neue Dinge

Von Hand heißt das jedes Mal: SVN auschecken, Dateien nach trunk kopieren, neue Dateien mit svn add anmelden, gelöschte mit svn delete abmelden, svn copy trunk tags/4.4.0, committen. Sechs Handgriffe, in der richtigen Reihenfolge, mit einem Passwort, das ich mir nie merke. Alle zwei Monate, also selten genug, dass ich jedes Mal in meinen alten Notizen nachgesehen habe, wie das nochmal ging.

Der Beweis, dass das nicht funktioniert, lag jahrelang im veröffentlichten trunk. Aus den Handbetrieb-Zeiten schleppte mein Plugin .project, .buildpath und .settings mit sich herum, Eclipse-Altlasten von einem Rechner, den ich längst nicht mehr habe. Aufgefallen ist es erst, als der offizielle WordPress Plugin Check bei mir in der Pipeline lief und mir sagte: „Hidden files are not permitted.“ Die Dateien lagen also im Store-Download, jahrelang, bei jedem, der das Plugin installiert hat. Beschwert hat sich nie jemand.

Ein zweiter Punkt, über den ich beim ersten Versuch gestolpert bin und der in keiner Doku fett genug steht: Das SVN-Passwort ist nicht dein wordpress.org-Passwort. Du legst es separat im Profil unter „Account & Security“ an. Ich war eine ganze Weile überzeugt, mein Zugang sei kaputt.

Die Grundidee: SVN ist kein Arbeitsplatz, sondern ein Ausgabegerät

ungeordneter Schreibtisch am Abend

Der Denkfehler war, das SVN als zweites Repository zu betrachten, in dem ich irgendwie auch arbeite. Sobald man es als Drucker begreift, löst sich die Sache auf. Git ist die Wahrheit. Das SVN bekommt fertige Ware geliefert und interessiert mich sonst nicht.

Praktisch bedeutet das: Kein Mensch checkt das Plugin-SVN mehr aus. Ein Skript kennt die sechs Handgriffe, die CI ruft es auf, und die Zugangsdaten liegen als maskierte CI/CD-Variablen in GitLab:

SVN_USERNAME = mein wordpress.org-Benutzername (case-sensitiv)
SVN_PASSWORD = das separate SVN-Passwort aus dem Profil

Das ist die ganze Konfiguration. Alles andere steckt in Skripten, die im Repo liegen und die ich lokal genauso aufrufen kann.

Was passiert, bevor überhaupt jemand an SVN denkt

Die Pipeline hat sechs Stufen: image, lint, test, quality, build, deploy. Bis es zum Deploy kommt, kann die Pipeline an vier Stellen vorher Nein sagen.

die moderne Werkbank

Stufe 0, das eigene CI-Image. Alle Jobs laufen in einem Base-Image, das die Pipeline selbst baut: PHP 8.1 mit den nötigen Extensions, Composer, wp-cli, MySQL-Client und, wichtig für später, subversion und rsync. Gebaut wird es mit Kaniko, also ohne Docker-Daemon und ohne Docker-in-Docker. Das Ergebnis landet in meiner eigenen Container-Registry, der Layer-Cache gleich daneben. Vorher hat jeder Job sein apt-get install selbst gemacht, was pro Lauf Minuten gekostet hat und bei jeder Netzstörung rot war.

Stufe 1, Lint. PHPCS mit dem WordPress-Coding-Standard, strikt auf den modernen Kern in plugin/src (null Errors), dazu die PHP-8.1-Kompatibilitätsprüfung auf dem gesamten Plugin. Das Plugin ist alt, es gibt noch prozedurale Dateien aus der Frühzeit. Die wandern nach und nach in plugin/src und werden dort dann automatisch mitgeprüft. Ich finde das ehrlicher, als den Standard auf alles zu werfen, tausend Findings zu sehen und den Job dann dauerhaft auf allow_failure zu stellen. Dazu PHPStan auf Level 5 mit phpstan-wordpress und composer audit für bekannte Schwachstellen in den Abhängigkeiten.

Stufe 2, Test. Die komplette PHPUnit-Suite gegen ein echtes WordPress. Als Service läuft mariadb:10.11, bin/install-wp-tests.sh zieht sich WordPress 6.5 samt Test-Library, dann läuft PHPUnit. Auf integration ist dieser Job die eigentliche Freigabe: Nur wenn er grün ist, darf nach master gemergt werden. Das erzwinge ich zusätzlich in GitLab über geschützte Branches und „Pipelines must succeed“, weil eine Regel, die nur in einem YAML-Kommentar steht, keine Regel ist.

Stufe 3, Quality. Hier läuft der offizielle Plugin Check von wordpress.org, also genau das Werkzeug, mit dem auch das Plugin-Team schaut. Der Job baut sich ein Wegwerf-WordPress in /tmp, hängt das Plugin per Symlink ein und prüft es. Die Schwelle steht bei warning, nicht bei error: null Fehler und null Warnungen, sonst bricht die Pipeline.

Das war unbequem. Die alten Warnungen, vor allem DirectDatabaseQuery an meinem lokalen Bild-Cache, musste ich einzeln durchgehen: echte Findings gefixt, die unvermeidbaren Fehlalarme mit einem begründeten phpcs:ignore versehen. Danach war die Liste leer, und seitdem ist jede neue Warnung ein echtes Signal. Gefunden hat der Job unter anderem die Eclipse-Altlasten von oben, die mir in fünf Jahren Handbetrieb nie aufgefallen sind.

Alte Bilder, alte Tags

Kleiner Nebeneffekt, der mich lange geärgert hat: Sobald ein Branch einen offenen Merge Request hat, lief bei mir alles doppelt, einmal als Branch-Pipeline und einmal als MR-Pipeline, auf demselben Commit. Drei Regeln im workflow-Block räumen das auf, und auto_cancel: on_new_commit: interruptible beendet alte Läufe, wenn ich nachschiebe. Bei einer Pipeline, die für jeden Lauf ein komplettes WordPress installiert, merkt man das direkt an der Wartezeit.

Der Build: was ins Paket gehört und was nicht

unterschiedliche Tags für unterschiedliche Dinge

Auf master entsteht das eigentliche Auslieferungspaket, per bin/build-plugin.sh. Das Skript macht zwei Dinge, die wichtiger sind als das Zippen selbst.

Erstens liest es die Version aus plugin/readme.txt, aus dem Feld Stable tag. Das ist bei wordpress.org die Angabe, die entscheidet, was ausgeliefert wird. Zweitens vergleicht es sie mit der Version: im Plugin-Header. Weichen die voneinander ab, bricht es ab:

VERSION=$(grep -i "^Stable tag:" "${PLUGIN_DIR}/readme.txt" | head -1 | sed 's/.*: *//' | tr -d '[:space:]')
HEADER_VERSION=$(grep -iE "^\s*Version:" "${PLUGIN_DIR}/portraitarchiv-store.php" | head -1 | sed 's/.*: *//' | tr -d '[:space:]')
if [ "$HEADER_VERSION" != "$VERSION" ]; then
    echo "FEHLER: Header-Version ($HEADER_VERSION) != readme Stable tag ($VERSION)"
    exit 1
fi

Der Grund für die Härte: Eine falsch getaggte Version bekommst du bei wordpress.org nicht mehr zurück. Es gibt kein Zurückziehen, nur ein weiteres Release hinterher, und der Store zeigt in der Zwischenzeit fröhlich die kaputte Version an.

Das Zip enthält einen Top-Level-Ordner mit dem Plugin-Slug, so wie WordPress es beim Installieren erwartet, und IDE- sowie VCS-Reste sind per rsync-Exclude ausgeschlossen. Es geht zusätzlich als Artefakt in die Pipeline, versioniert, 5 Tage haltbar. Damit habe ich für jede ausgelieferte Version das exakte Paket zum Download, ohne im SVN zu wühlen.

Der Deploy: vierzig Zeilen, die das SVN erledigen

bin/deploy-svn.sh ist der Kern. Der Ablauf ist genau der Handbetrieb von früher, nur eben aufgeschrieben.

Version lesen, SVN auschecken, trunk synchronisieren:

svn checkout --quiet "$SVN_URL" "$BUILD_DIR"

rsync -a --delete \
    --exclude='.svn' --exclude='.git' \
    --exclude='.settings' --exclude='.project' --exclude='.buildpath' \
    "${PLUGIN_DIR}/" "${BUILD_DIR}/trunk/"

--delete ist hier das entscheidende Flag. Ohne das wächst der trunk bei jedem Release, weil gelöschte Dateien einfach liegen bleiben. Genau so entstehen die Altlasten, die ich oben beschrieben habe.

Danach die Store-Assets aus .wordpress-org nach assets, ebenfalls mit --delete. Dann der Schritt, den man von Hand am liebsten vergisst: SVN muss man mitteilen, welche Dateien neu sind und welche verschwunden sind.

svn add --force . --quiet
svn status | awk '/^!/ {print $2}' | xargs -r svn delete --quiet

Das ! in der Statusausgabe steht für „laut SVN vorhanden, im Dateisystem weg“. Ohne diese Zeile scheitert der Commit oder, schlimmer, er läuft durch und lässt Dateien im Verzeichnis, die du längst gelöscht hast.

Dann der Tag. Und zwar bedingt:

if svn ls "${SVN_URL}/tags/${VERSION}" >/dev/null 2>&1; then
    echo "==> Tag ${VERSION} existiert bereits im SVN – überspringe Tag-Erstellung"
else
    svn copy --quiet "trunk" "tags/${VERSION}"
fi

Das ist mir wichtiger geworden, als es aussieht. Der Deploy läuft bei mir auf jedem master-Commit, also auch bei einem Nachtrag am Changelog ohne Versionsbump. Wenn der Job dann am schon existierenden Tag stirbt, ist meine Pipeline rot, obwohl alles in Ordnung ist. Seitdem kann ich den Job zweimal laufen lassen, ohne vorher nachzudenken, und traue mich deshalb auch, ihn mal von Hand anzustoßen.

Am Ende ein einziger Commit, der trunk, assets und den neuen Tag zusammen veröffentlicht:

svn commit --username "$SVN_USERNAME" --password "$SVN_PASSWORD" \
    --non-interactive --no-auth-cache -m "Release ${VERSION}"

Zwei Details noch. Das Skript kennt DRY_RUN=1, dann bereitet es alles vor und zeigt nur den svn status. So habe ich es entwickelt, ohne das öffentliche Verzeichnis als Testumgebung zu benutzen. Und in der Pipeline steht der Job auf interruptible: false. Alle anderen Jobs dürfen abgebrochen werden, wenn ich einen neuen Commit nachschiebe. Ein halb übertragener SVN-Commit dagegen ist öffentlich sichtbarer Murks.

Der Git-Tag: dieselbe Version in beiden Welten

Nach erfolgreichem SVN-Push setzt ein zweiter Job denselben Tag im Git, also 4.4.0 hier und tags/4.4.0 dort. Damit finde ich Monate später zu jeder ausgelieferten Version den Commit, ohne zu raten. Bei einer Support-Anfrage ist das immer die erste Frage, die ich mir selbst stellen muss: Welchen Stand hat der Kunde da eigentlich installiert?

sauber sortierte Pakete mit Tags

Und hier bin ich in eine Falle gelaufen, die nichts mit WordPress zu tun hat. Der eingebaute Weg über das release:-Keyword in GitLab CI hat bei mir nicht funktioniert: Der Runner-Step delegiert intern an glab, und glab kennt keine Option, die TLS-Prüfung zu überspringen. Meine GitLab-Instanz läuft mit selbstsigniertem Zertifikat, also endete jeder Versuch bei x509: certificate signed by unknown authority. Die Lösung war, release-cli direkt im Script aufzurufen. Das spricht die Releases-API an, akzeptiert --insecure-https und liest Server-URL, Projekt-ID und Job-Token selbst aus den vordefinierten CI-Variablen.

tag:release:
  stage: deploy
  needs: ["deploy:svn"]
  interruptible: false
  image: registry.gitlab.com/gitlab-org/release-cli:latest
  script:
    - 'VERSION=$(grep -i "^Stable tag:" plugin/readme.txt | head -1 | sed ''s/.*: *//'' | tr -d ''[:space:]'')'
    - >
      release-cli --insecure-https create
      --tag-name "$VERSION" --ref "$CI_COMMIT_SHA" --name "$VERSION"
      --description "Release $VERSION - Changelog siehe plugin/readme.txt"

Wer eine GitLab-Instanz mit ordentlichem Zertifikat hat, kann bei release: bleiben. Ich schreibe es trotzdem hin, weil die Fehlermeldung nach einem Zertifikatsproblem im Repository aussieht und man erstmal an der völlig falschen Stelle sucht.

Was ich beim zweiten Plugin anders gemacht habe

Vor ein paar Wochen habe ich für einen Sportverein hier im Ort ein Plugin gebaut. Die sind mit ihrer Verwaltung ziemlich am Anschlag, Termine, Mannschaften, Ansprechpartner, das alles per Mail und Excel. Weil ich das Ergebnis sowieso brauchbar finde, wollte ich es auch veröffentlichen, und damit stand ich wieder vor derselben SVN-Strecke.

Diesmal war es eine Stunde Arbeit statt eines Abends, weil das Muster stand. Vier Dinge habe ich dabei bewusst anders gelöst, und die zeigen ziemlich gut, wo die Shop-Pipeline noch die erste Ausbaustufe ist.

sortierte Artefakte

Online geht das Artefakt, nicht das Repository. Im Shop synchronisiert das Deploy-Skript den plugin/-Ordner aus dem Arbeitsverzeichnis. Im neuen Plugin ist die Quelle ausschließlich das gebaute Zip aus dem Build-Job. Das Skript entpackt es und schiebt genau diesen Inhalt ins SVN. Der Unterschied ist keine Kosmetik: So kann prinzipiell nichts online gehen, was nicht vorher gebaut, getestet und geprüft wurde, und Entwicklungsdateien können gar nicht erst mitrutschen.

Der Checkout bleibt schlank. Ein vollständiger Checkout des Plugin-SVN holt jedes alte Release mit. Beim ersten Plugin fällt das nicht auf, bei Version 40 schon. Also sparse:

svn checkout --quiet --depth immediates "$SVN_URL" "$CHECKOUT"
# trunk und assets vollständig, tags nur als Verzeichnisliste
svn update --quiet --set-depth infinity trunk
svn update --quiet --set-depth immediates tags

Der Inhalt alter Tags interessiert niemanden, ich muss nur wissen, welche es schon gibt.

Der Deploy ist sichtbar, wartet aber auf mich. Im Shop läuft er auf jedem master-Commit automatisch. Beim neuen Plugin steht er als manueller Job in der Pipeline und wartet auf den Play-Knopf, und mit einer gesetzten Variable WPORG_DEPLOY=1 läuft er automatisch durch. Zwei Regelzweige, weil GitLab einen Job, dessen Regel nicht greift, gar nicht anlegt, und eine leere Stage zeigt die Oberfläche nicht an. Mir war wichtig, dass ich die Stage sehe, auch wenn gerade nichts rausgehen soll.

Der Tag-Job stellt die richtige Frage. Das ist der Fehler, auf den ich am wenigsten stolz bin. Im Shop hängt der Tag-Job an einer changes-Regel auf plugin/readme.txt, damit er nur bei einem Versionsbump feuert. Klingt vernünftig, ist aber die falsche Frage. Die Regel fragt „hat sich diese Datei in diesem Commit geändert?“, entscheidend wäre „fehlt der Tag?“. Am 29. August ist mir das aufgefallen: Ein Deploy, den ich von Hand angestoßen hatte, blieb ohne Git-Tag zurück, weil der Commit die readme nicht angefasst hatte. Der neue Job läuft nach jedem erfolgreichen SVN-Deploy und entscheidet selbst:

Paketierungspipeline
if release-cli --insecure-https get --tag-name "$VERSION" >/dev/null 2>&1; then
  echo "==> Release ${VERSION} existiert bereits – kein neuer Tag."
  exit 0
fi

Dieselbe Idempotenz-Idee wie beim SVN-Tag. Prüfe den Zustand, nicht das Ereignis.

Dazu noch eine Kleinigkeit, die ich beim zweiten Mal sauberer gelöst habe: Die Version steht dort an drei Stellen, in der readme.txt, im Plugin-Header und in einer PHP-Konstante fürs Cache-Busting. Es gibt genau ein Skript, das sie ermittelt und dabei alle drei auf Gleichheit prüft. Build, Deploy und Tag greifen darauf zu. Im Shop steckt derselbe Vergleich noch im Build-Skript selbst.

Was es gebracht hat

Ein Release ist bei mir jetzt ein Merge nach master und, beim neuen Plugin, ein Klick. Ich muss nichts über Subversion wissen, während ich das mache. Der Teil, der früher Konzentration gebraucht hat, ist der Teil, über den ich am wenigsten nachdenke.

Paketierungspipeline

Der zweite Gewinn ist der, mit dem ich nicht gerechnet habe: Weil das Deployen billig geworden ist, veröffentliche ich häufiger. Vorher habe ich Fixes gesammelt, bis sich der Aufwand „lohnt“. Version 4.3.0 mit den signierten Bild-URLs wäre früher ein Ereignis gewesen, für das ich mir einen ruhigen Abend freihalte.

Wenn ich nochmal bei null anfinge, würde ich zwei Dinge sofort anders machen: das gebaute Artefakt deployen und nicht das Arbeitsverzeichnis, und den Plugin Check vom ersten Tag an als hartes Gate laufen lassen, bevor sich Altlasten ansammeln, die dann jahrelang im Store liegen. Die vier Punkte aus dem zweiten Plugin ziehe ich beim Shop nach, das steht auf der Liste.

Und das SVN? Interessiert mich inzwischen ungefähr so sehr wie der Druckertreiber.

geordneter Schreibtisch am Morgen

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert