Making-of: Eigene WordPress-Plugins per GitHub Actions ausliefern und auto-updaten

Wie ein WordPress-Plugin außerhalb des .org-Verzeichnisses den Ein-Klick-Update-Komfort bekommt: GitHub Actions baut ZIP und Prüfsumme, ein statisches JSON-Manifest auf gh-pages ersetzt die rate-limitierte GitHub-API, und eine schlanke Updater-Klasse hängt sich sicher ins WordPress-Update-System.

von ·

Wer ein WordPress-Plugin schreibt, das nicht im offiziellen wordpress.org-Verzeichnis liegt — weil es zu speziell ist, intern bleibt oder schlicht dort nicht hingehört — verliert eine Sache, die man erst vermisst, wenn sie weg ist: den Ein-Klick-Update-Komfort im Backend. Kein „Version 1.3.0 verfügbar", kein blauer Aktualisieren-Button, kein automatisches Einspielen. Stattdessen: ZIP runterladen, per FTP hochladen, alten Ordner löschen, hoffen.

Das muss nicht sein. WordPress' Update-System ist erweiterbar — man kann ein Plugin so bauen, dass es seine Updates aus einer eigenen Quelle bezieht und sich trotzdem exakt so anfühlt wie ein Verzeichnis-Plugin. Dieser Artikel ist das Making-of eines solchen Setups: Eine GitHub-Actions-Pipeline baut bei jedem Versions-Tag automatisch das ZIP, eine Prüfsumme und ein JSON-Manifest; das Manifest liegt statisch auf GitHub Pages (gh-pages) — und eine kompakte Updater-Klasse im Plugin liest es aus und klinkt sich sicher ins WordPress-Update-System ein.

Der Clou steckt in der Kombination: Weil das Manifest eine statische Datei auf gh-pages ist und nicht die GitHub-API, laufen tausende WordPress-Installationen problemlos gegen dieselbe Quelle, ohne je in ein Rate-Limit zu rennen. Das Plugin selbst nenne ich hier bewusst nicht — die Mechanik ist das Interessante, und die lässt sich auf jedes eigene Plugin übertragen. In den Code-Beispielen steht überall der Platzhalter-Slug jpkcom-example.

Die drei Bausteine und der Datenfluss

Bevor wir in den Code gehen, das Gesamtbild. Es sind drei Teile, die über genau eine Aktion zusammenspielen: einen Git-Tag.

  1. Die Release-Pipeline (GitHub Actions, release.yml). Läuft, sobald ein Tag wie v1.3.0 gepusht wird. Sie baut das Plugin-ZIP, berechnet dessen SHA256-Prüfsumme, hängt beides an ein GitHub-Release und erzeugt aus dem README.md ein JSON-Manifest.
  2. Das Manifest auf gh-pages. Eine statische plugin_jpkcom-example.json, die auf dem gh-pages-Branch deployt und über GitHubs CDN ausgeliefert wird. Sie ist die „Wahrheit" darüber, welche Version aktuell ist, wo das ZIP liegt und welche Prüfsumme es haben muss.
  3. Die Updater-Klasse im Plugin (PHP). Sie fragt das Manifest ab, vergleicht die Version mit der installierten, meldet WordPress bei Bedarf ein Update — und verifiziert vor der Installation die Prüfsumme.

Der Datenfluss ist eine Einbahnstraße vom Tag bis in den wp-admin-Update-Screen:

git tag v1.3.0 && git push --tags


GitHub Actions (release.yml)
        ├── baut  jpkcom-example.zip
        ├── rechnet SHA256
        ├── lädt ZIP + .sha256 ins GitHub-Release
        └── generiert  plugin_jpkcom-example.json  ──► Deploy nach gh-pages


                                       https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json

                        (statischer Abruf, kein API-Token, kein Rate-Limit)


                                  Updater-Klasse im Plugin  ──►  wp-admin: „Version 1.3.0 verfügbar"

Jeder Teil hat genau eine Aufgabe und eine klare Schnittstelle: Die Pipeline produziert das Manifest, das Manifest beschreibt das Release, die Klasse konsumiert das Manifest. Man kann jeden Teil isoliert verstehen und testen — und genau das gehen wir jetzt der Reihe nach durch.

Teil 1 — GitHub Actions: vom Git-Tag zum Release

Die Pipeline startet ausschließlich bei Versions-Tags. Das ist die halbe Miete für ein sauberes Release: Nicht jeder Commit löst etwas aus, sondern nur die bewusste Handlung „das ist jetzt Version X".

name: Build WordPress Plugin ZIP, Generate Manifest & Deploy

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: write   # nötig, um Release-Assets und den gh-pages-Branch zu schreiben

permissions: contents: write ist bewusst minimal gehalten: Der eingebaute GITHUB_TOKEN bekommt genau die Schreibrechte, die er für Release und gh-pages braucht — nicht mehr. Kein persönlicher Access-Token, kein Secret, das man verwalten und rotieren müsste.

Variablen aus dem Tag ableiten

Der erste Job-Schritt zieht alle Eckdaten aus dem Kontext, statt sie irgendwo zu duplizieren. Der Slug ist der Repo-Name, die Version der Tag ohne führendes v, und die Download-URL zeigt schon auf das Release-Asset, das gleich erst entsteht:

echo "PLUGIN_SLUG=$(basename $GITHUB_REPOSITORY)" >> $GITHUB_ENV
TAG_NAME=${GITHUB_REF##*/}          # z. B. "v1.3.0"
TAG_NAME=${TAG_NAME#v}              # -> "1.3.0"
echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV
echo "ZIP_FILE=$(basename $GITHUB_REPOSITORY).zip" >> $GITHUB_ENV
echo "DOWNLOAD_URL=https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF##*/}/$(basename $GITHUB_REPOSITORY).zip" >> $GITHUB_ENV

Der entscheidende Gedanke: Es gibt keine hartcodierte Versionsnummer irgendwo im Workflow. Der Tag ist die einzige Quelle. Wer v1.3.0 pusht, bekommt ein 1.3.0-Release mit passendem ZIP-Namen und passender Download-URL — ohne dass man eine Datei anfassen müsste.

Das ZIP bauen — mit den richtigen Ausschlüssen

Ein Plugin-ZIP darf nur enthalten, was auch auf der Zielinstallation landen soll. Alles Entwicklungs-Drumherum — .git, die Workflow-Definition, Build-Skripte, Doku-Generatoren — muss raus, sonst bläht es das Paket auf oder verrät Interna. rsync mit einer Exclude-Liste in ein sauber benanntes Staging-Verzeichnis ist dafür das robuste Werkzeug:

STAGING_DIR="${{ env.PLUGIN_SLUG }}"
mkdir -p "$STAGING_DIR"
rsync -a \
  --exclude='.git' --exclude='.gitignore' --exclude='.github' \
  --exclude='.DS_Store' --exclude='*.py' \
  --exclude='CLAUDE.md' --exclude='.claude' \
  --exclude='gh-pages-*' --exclude='docs' --exclude='phpdoc*' \
  . "$STAGING_DIR/"
zip -r "${{ env.ZIP_FILE }}" "$STAGING_DIR"
rm -rf "$STAGING_DIR"

Wichtig ist der Zwischenschritt über $STAGING_DIR: Das ZIP enthält dadurch einen einzigen Top-Level-Ordner mit dem Plugin-Slug — genau die Struktur, die WordPress beim Entpacken erwartet. Ein ZIP, das seine Dateien flach im Root hat, würde das Plugin bei der Installation an der falschen Stelle ablegen.

Prüfsumme: das Sicherheits-Fundament

Direkt nach dem ZIP kommt der Schritt, der später die ganze Sicherheitskette trägt — die SHA256-Prüfsumme. Sie wird berechnet, in eine Umgebungsvariable geschrieben (fürs Manifest) und als eigene .sha256-Datei abgelegt (zum manuellen Nachprüfen):

SHA256_HASH=$(sha256sum "${{ env.ZIP_FILE }}" | awk '{print $1}')
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
echo "$SHA256_HASH  ${{ env.ZIP_FILE }}" > ${{ env.ZIP_FILE }}.sha256

Diese Zahl ist der Fingerabdruck exakt des ZIPs, das die Pipeline gerade gebaut hat. Sie wandert gleich ins Manifest — und die Updater-Klasse wird sich weigern, irgendetwas zu installieren, das diesen Fingerabdruck nicht trägt. Dazu später mehr.

Ins Release hochladen

Das Hochladen übernimmt eine etablierte Action. generate_release_notes: true lässt GitHub die Release-Notes automatisch aus den Commits seit dem letzten Tag zusammenstellen:

- name: Upload ZIP and checksum to GitHub release
  uses: softprops/action-gh-release@v2
  with:
    tag_name: ${{ github.ref_name }}
    generate_release_notes: true
    files: |
      ${{ env.ZIP_FILE }}
      ${{ env.ZIP_FILE }}.sha256
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Ab hier ist das ZIP unter der oben gebauten DOWNLOAD_URL öffentlich abrufbar — und zwar über die Release-Download-Infrastruktur von GitHub, nicht über die API. Dieser Unterschied ist der Kern des Rate-Limit-Themas weiter unten.

Das Manifest generieren

Der letzte inhaltliche Schritt baut die JSON-Datei, die WordPress später liest. Ich mache das in einem Python-Step (shell: python), weil das Zusammensetzen verschachtelter JSON-Strukturen in Bash schnell unleserlich wird. Vereinfacht sieht der Kern so aus:

import os, json

plugin = {
    "name":            meta.get("PluginName", ""),
    "slug":            os.environ["PLUGIN_SLUG"],
    "version":         meta.get("Version", os.environ["TAG_NAME"]),
    "download_url":    os.environ["DOWNLOAD_URL"],
    "checksum_sha256": os.environ.get("SHA256_HASH", ""),   # <- der Fingerabdruck
    "requires":        meta.get("Requiresatleast", "6.8"),
    "tested":          meta.get("Testedupto", "6.9"),
    "requires_php":    meta.get("RequiresPHP", "8.3"),
    "last_updated":    os.environ["DATE"],
    "sections": {                                            # HTML fürs "Details"-Modal
        "description":  load_section_html("description"),
        "installation": load_section_html("installation"),
        "changelog":    load_section_html("changelog"),
        "faq":          load_section_html("faq"),
    },
}

with open(f"gh-pages-json/plugin_{os.environ['PLUGIN_SLUG']}.json", "w", encoding="utf-8") as f:
    json.dump(plugin, f, indent=2, ensure_ascii=False)

Die meta-Werte und die sections stammen aus dem README.md: Ein vorgelagerter Schritt zieht mit pandoc und awk die Abschnitte ## Description, ## Installation, ## Changelog und ## FAQ heraus und rendert sie zu HTML — genau das, was WordPress im „Details ansehen"-Modal eines Plugins zeigt. So bleibt das README.md die einzige Quelle für die Beschreibung: einmal pflegen, überall (GitHub, Manifest, WP-Backend) aktuell.

Nach gh-pages deployen

Zum Schluss landet das Manifest (plus die gerenderten HTML-Schnipsel und die Banner/Icons) auf dem gh-pages-Branch:

- name: Deploy to gh-pages
  uses: peaceiris/actions-gh-pages@v4
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    publish_dir: gh-pages-deploy
    publish_branch: gh-pages
    keep_files: false

keep_files: false sorgt dafür, dass der gh-pages-Branch bei jedem Release komplett neu geschrieben wird — es sammelt sich also kein Altbestand an. Sobald der Branch als GitHub Pages veröffentlicht ist, liegt das Manifest unter einer stabilen, statischen URL:

https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json

Damit ist Teil 1 fertig: Ein git push --tags genügt, und wenige Minuten später existieren ein Release-ZIP mit Prüfsumme und ein aktualisiertes Manifest. Kein manueller Schritt dazwischen.

Teil 2 — Das Manifest: der Vertrag zwischen Pipeline und Plugin

Das Manifest ist bewusst nur eine schlichte JSON-Datei. Es ist der Vertrag: Die Pipeline verpflichtet sich, ihn korrekt zu füllen, das Plugin verlässt sich darauf, ihn korrekt zu lesen. Ausschnitt:

{
  "name": "JPKCom Example",
  "slug": "jpkcom-example",
  "version": "1.3.0",
  "download_url": "https://github.com/JPKCom/jpkcom-example/releases/download/v1.3.0/jpkcom-example.zip",
  "checksum_sha256": "9f2c…a1b7",
  "requires": "6.8",
  "tested": "6.9",
  "requires_php": "8.3",
  "last_updated": "2026-07-06",
  "sections": {
    "description": "<p>…</p>",
    "changelog": "<h4>1.3.0</h4><ul><li>…</li></ul>"
  }
}

Nur eine Handvoll Felder trägt die eigentliche Update-Logik:

Feld Wofür WordPress es braucht
version Vergleich gegen die installierte Version — nur wenn neuer, gibt es ein Update
download_url Woher das ZIP gezogen wird (das Release-Asset)
checksum_sha256 Der Fingerabdruck, gegen den vor der Installation geprüft wird
requires / tested Kompatibilitätsanzeige im Backend (WordPress-Version)
requires_php Blockiert das Update auf zu alten PHP-Versionen
sections HTML fürs „Details ansehen"-Modal (Beschreibung, Changelog, FAQ)

Dass das ein statisches Dokument ist, ist kein Kompromiss, sondern der eigentliche Trick. Es gibt keinen Server, der es dynamisch erzeugt, keine Datenbank, keinen PHP-Prozess auf der Gegenseite. Es ist eine Datei auf einem CDN. Das macht es schnell, ausfallsicher und — der springende Punkt — frei von Rate-Limits.

Teil 3 — Die Updater-Klasse: sauber ins WordPress-Update-System einhängen

Jetzt zur Konsumentenseite. WordPress prüft periodisch, ob es für installierte Plugins Updates gibt, und stellt dafür Filter bereit, in die man sich einklinken kann. Die Klasse (hier JPKComGitPluginUpdater) macht im Konstruktor zweierlei: Sie validiert die Umgebung und die Manifest-URL, und sie registriert ihre Hooks.

public function __construct( string $plugin_file, string $current_version, string $manifest_url ) {
    global $wp_version;

    // Umgebungs-Gate: unter PHP 8.3 / WP 6.8 gar nicht erst aktiv werden
    if ( version_compare( PHP_VERSION, '8.3', '<' )
         || version_compare( $wp_version, '6.8', '<' ) ) {
        return;
    }

    // Sicherheit: Manifest-URL säubern und validieren, bevor irgendetwas passiert
    $manifest_url = esc_url_raw( $manifest_url );
    if ( ! wp_http_validate_url( $manifest_url ) ) {
        return; // ungültige URL -> Initialisierung abbrechen
    }

    $this->plugin_file     = $plugin_file;
    $this->plugin_slug     = dirname( plugin_basename( $plugin_file ) );
    $this->current_version = $current_version;
    $this->manifest_url    = $manifest_url;
    $this->cache_key       = 'jpk_git_update_' . md5( $this->plugin_slug );

    add_filter( 'plugins_api',                    [ $this, 'plugin_info' ], 20, 3 );
    add_filter( 'site_transient_update_plugins',  [ $this, 'check_update' ] );
    add_action( 'upgrader_process_complete',      [ $this, 'clear_cache' ], 10, 2 );
    add_filter( 'upgrader_pre_download',          [ $this, 'verify_download_checksum' ], 10, 3 );
}

Die vier Hooks entsprechen vier klaren Aufgaben:

  • site_transient_update_pluginscheck_update(): meldet WordPress, dass ein Update vorliegt.
  • plugins_apiplugin_info(): füllt das „Details ansehen"-Modal aus dem Manifest.
  • upgrader_pre_downloadverify_download_checksum(): prüft die Prüfsumme, bevor installiert wird.
  • upgrader_process_completeclear_cache(): wirft den Cache nach erfolgreichem Update weg.

Instanziiert wird die Klasse beim Laden des Plugins — mit der eigenen Version und der gh-pages-Manifest-URL:

new \JPKComHideLoginGitUpdate\JPKComGitPluginUpdater(
    plugin_file:     __FILE__,
    current_version: JPKCOM_EXAMPLE_VERSION,
    manifest_url:    'https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json'
);

Das Manifest holen — mit Cache und Race-Schutz

Das Herzstück ist get_remote_manifest(). Es tut drei Dinge gleichzeitig: Es cached das Ergebnis 24 Stunden, es verhindert per Lock, dass mehrere gleichzeitige Requests dasselbe Manifest parallel holen, und es holt es SSRF-sicher.

private function get_remote_manifest(): ?object {
    $remote = get_transient( $this->cache_key );

    if ( false === $remote ) {
        // Race-Schutz: läuft schon ein anderer Abruf? Dann diesen aussetzen.
        $lock_key = $this->cache_key . '_lock';
        if ( get_transient( $lock_key ) ) {
            return null;
        }
        set_transient( $lock_key, true, 30 );   // Lock für 30 Sekunden

        $response = wp_safe_remote_get( $this->manifest_url, [
            'timeout' => 15,
            'headers' => [ 'Accept' => 'application/json' ],
        ] );

        delete_transient( $lock_key );

        if ( is_wp_error( $response )
             || wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return null;   // im WP_DEBUG-Modus wird hier zusätzlich geloggt
        }

        $remote = json_decode( wp_remote_retrieve_body( $response ) );
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            return null;
        }

        set_transient( $this->cache_key, $remote, DAY_IN_SECONDS );
    }

    return is_object( $remote ) ? $remote : null;
}

Drei Details, die den Unterschied zwischen „funktioniert" und „funktioniert unter Last" machen:

  • wp_safe_remote_get() statt wp_remote_get(). Die „safe"-Variante blockt Requests auf interne/private IP-Bereiche — Schutz gegen SSRF, falls die Manifest-URL je manipuliert würde.
  • Der Lock. Auf einer stark frequentierten Seite können mehrere Requests gleichzeitig feststellen, dass der Cache leer ist. Ohne Lock würden sie alle parallel das Manifest holen. Der 30-Sekunden-Lock sorgt dafür, dass genau einer geht und die anderen diesen Zyklus einfach auslassen.
  • Der 24-Stunden-Cache (DAY_IN_SECONDS). Jede Installation fragt das Manifest höchstens einmal pro Tag ab. Das ist gleichzeitig freundlich zur Quelle und mehr als ausreichend aktuell für Plugin-Updates.

Das Update melden

check_update() ist danach fast unspektakulär — und das ist gut so. Cache leer prüfen, Version vergleichen, bei „neuer" ein Update-Objekt bauen:

public function check_update( mixed $transient ): object {
    if ( empty( $transient->checked ) ) {
        return $transient;
    }

    $remote = $this->get_remote_manifest();
    if ( ! $remote || empty( $remote->version ) ) {
        return $transient;
    }

    if ( version_compare( $this->current_version, $remote->version, '<' ) ) {
        $download_url = $remote->download_url ?? '';
        if ( ! empty( $download_url ) && ! wp_http_validate_url( $download_url ) ) {
            return $transient;   // ungültige Download-URL -> kein Update anbieten
        }

        $update              = new \stdClass();
        $update->slug        = $this->plugin_slug;
        $update->new_version = sanitize_text_field( $remote->version );
        $update->package     = esc_url_raw( $download_url );   // hier zieht WP das ZIP
        $update->plugin      = plugin_basename( $this->plugin_file );

        $transient->response[ plugin_basename( $this->plugin_file ) ] = $update;
    }

    return $transient;
}

Sobald dieses $update->response-Objekt gesetzt ist, zeigt WordPress im Backend das vertraute „Version 1.3.0 verfügbar" mit Aktualisieren-Button — nicht zu unterscheiden von einem Verzeichnis-Plugin. plugin_info() (hier nicht abgedruckt) füllt parallel das „Details ansehen"-Modal aus den sections des Manifests.

Sicherheit: warum eine URL allein nie genügt

Ein selbstgehostetes Update-System ist eine mächtige Sache — man sagt WordPress: „Installiere, was unter dieser URL liegt." Wenn diese URL, das Manifest oder die Verbindung dorthin kompromittiert würde, installierte man beliebigen Code auf jeder angeschlossenen Seite. Deshalb steht und fällt das Konzept mit einer Regel: Eine Download-URL allein ist kein Vertrauensbeweis. Erst die Prüfsumme macht sie glaubwürdig.

Das Prüfsummen-Gate

Der wichtigste Hook ist upgrader_pre_download. Er feuert, bevor WordPress das Paket installiert, und darf den Vorgang mit einem WP_Error abbrechen. Genau dort wird die im Manifest hinterlegte SHA256 gegen das tatsächlich heruntergeladene ZIP geprüft:

public function verify_download_checksum( $reply, string $package, \WP_Upgrader $upgrader ) {
    // Betrifft dieser Download überhaupt unser Plugin?
    $remote = $this->get_remote_manifest();
    // (Abgleich gegen manifest->download_url bzw. den Slug – hier gekürzt)

    if ( ! $remote || empty( $remote->checksum_sha256 ) ) {
        return $reply;   // ohne Prüfsumme im Manifest: nicht blockieren (Abwärtskompatibilität)
    }

    // Paket temporär laden und Hash berechnen
    $temp_file = download_url( $package );
    if ( is_wp_error( $temp_file ) ) {
        return new \WP_Error( 'download_failed', $temp_file->get_error_message() );
    }
    $calculated_hash = hash_file( 'sha256', $temp_file );
    @unlink( $temp_file );

    // Timing-sicherer Vergleich
    $expected_hash = strtolower( trim( $remote->checksum_sha256 ) );
    if ( ! is_string( $calculated_hash ) || ! hash_equals( $expected_hash, $calculated_hash ) ) {
        return new \WP_Error(
            'checksum_mismatch',
            __( 'Sicherheitsprüfung fehlgeschlagen: Prüfsummen stimmen nicht überein.', 'jpkcom-example' )
        );
    }

    return $reply;   // Prüfsumme passt -> Installation freigeben
}

Der Effekt: Selbst wenn ein Angreifer es schaffte, die Download-URL im Manifest auf ein manipuliertes ZIP umzubiegen, müsste er zusätzlich dessen SHA256 im selben Manifest hinterlegen — und selbst dann bliebe die Frage, wie er das Manifest überhaupt verändert. Stimmt der Hash nicht exakt, bricht die Installation mit einer klaren Fehlermeldung ab. Nichts wird eingespielt.

Zwei Feinheiten sind hier wichtig:

  • hash_equals() statt ===. Der Vergleich läuft timing-sicher, damit sich aus der Vergleichsdauer keine Rückschlüsse auf den erwarteten Hash ziehen lassen. Bei Prüfsummen ist das eher Prinzip als akute Gefahr — aber es ist die richtige Gewohnheit.
  • Der Hash wird normalisiert (strtolower, trim), bevor verglichen wird, damit ein harmloser Whitespace oder Groß-/Kleinschreibungs-Unterschied im Manifest nicht fälschlich als Manipulation durchgeht.

Sicherheit in der Tiefe

Die Prüfsumme ist die letzte Verteidigungslinie, aber nicht die einzige. Über die gesamte Klasse zieht sich das Prinzip, jeder aus dem Manifest gelesenen Angabe zu misstrauen, bis sie validiert und gesäubert ist:

  • URLs werden konsequent durch wp_http_validate_url() geprüft und mit esc_url_raw() gesäubert — die Manifest-URL selbst, jede Download-URL, jede Banner- und Icon-URL.
  • Textfelder aus dem Manifest (version, author, tested …) laufen vor der Ausgabe durch sanitize_text_field(), HTML-Abschnitte durch wp_kses_post(). Das Manifest darf schließlich nie ungefiltertes HTML ins Backend schreiben.
  • wp_safe_remote_get() verhindert, dass der Abruf auf interne Netzwerkadressen umgelenkt werden kann (SSRF).
  • Das Umgebungs-Gate im Konstruktor stellt sicher, dass die Klasse auf veralteten PHP-/WP-Versionen gar nicht erst aktiv wird, statt dort unvorhersehbar zu scheitern.

Keine dieser Maßnahmen ist für sich spektakulär. Zusammen bilden sie eine Kette, bei der jedes Glied davon ausgeht, dass die anderen versagen könnten — und genau das ist der Punkt.

Unabhängig von Rate-Limits: warum gh-pages statt GitHub-API

Jetzt zu dem Punkt, der dieses Setup gegenüber vielen fertigen „GitHub-Updater"-Bibliotheken auszeichnet. Der naheliegende Weg, das neueste Release eines Repos zu ermitteln, ist die GitHub-REST-API — etwa api.github.com/repos/OWNER/REPO/releases/latest. Viele Update-Checker-Libraries machen genau das. Und genau da liegt das Problem.

Die GitHub-API limitiert unauthentifizierte Requests auf 60 pro Stunde und IP-Adresse. Das klingt nach viel, ist es aber nicht:

  • Auf Shared Hosting teilen sich oft dutzende oder hunderte WordPress-Installationen dieselbe ausgehende IP. Sie alle würden gegen dasselbe 60er-Kontingent laufen.
  • Sobald das Limit erschöpft ist, antwortet die API mit 403 — der Update-Check schlägt fehl, und zwar still: Die Seite denkt, es gebe kein Update.
  • Der naheliegende „Fix", einen API-Token einzubauen, ist keiner: Ein persönlicher Token im ausgelieferten Plugin-Code wäre ein handfestes Sicherheitsleck.

Der Ausweg ist, die API komplett zu umgehen. GitHub Pages ist kein API-Endpunkt, sondern statisches Hosting über ein CDN. Ein Abruf von

https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json

ist für GitHub dasselbe wie der Abruf einer beliebigen statischen Webseite: kein Token, kein Auth-Kontext, kein API-Rate-Limit. Dasselbe gilt für den ZIP-Download aus dem Release: github.com/…/releases/download/… läuft über die Download-Infrastruktur, nicht über api.github.com. Beide Schritte des Update-Vorgangs — Manifest lesen und ZIP ziehen — berühren die rate-limitierte API also nie.

Der Kontrast in der Übersicht:

GitHub-API-Weg gh-pages-Manifest (dieses Setup)
Endpunkt api.github.com/... statische Datei auf CDN
Rate-Limit (ohne Token) 60 Requests/h pro IP praktisch keins für normalen Traffic
Verhalten auf Shared Hosting IP-Kontingent geteilt → früh erschöpft jede Seite bekommt ihre Antwort
Token nötig für Entlastung ja (und ein Risiko im Plugin) nein
Ausfallverhalten 403, Update-Check schlägt still fehl statische Datei, so verfügbar wie das CDN

Und die 24-Stunden-Transient plus der Lock aus der Updater-Klasse legen noch eine Schippe drauf: Selbst wenn tausende Seiten dasselbe Manifest nutzen, fragt jede es höchstens einmal täglich ab — und pro Seite geht bei gleichzeitigen Requests nur einer wirklich raus. Das Setup ist damit von Grund auf so gebaut, dass es beliebig skaliert, ohne je an eine Grenze zu stoßen.

Was bewusst fehlt — und die Grenzen

Ein ehrliches Making-of nennt auch, was das Setup nicht leistet:

  • Kein automatisches Rollback. Schlägt ein Update fehl oder ist eine Version fehlerhaft, gibt es keinen eingebauten Zurück-Schalter. Die Prüfsumme verhindert die Installation manipulierter Pakete, nicht die eines fehlerhaft veröffentlichten. Disziplin beim Taggen bleibt die eigentliche Absicherung.
  • Prüfsumme, keine Signatur. SHA256 belegt, dass das ZIP unverändert das ist, was das Manifest beschreibt — es beweist nicht kryptografisch, wer es erstellt hat. Die nächste Ausbaustufe wäre eine echte Signatur (etwa via Sigstore / cosign oder GPG), die auch die Herkunft beweisbar macht. Für ein selbst kontrolliertes Repo mit geschütztem contents: write ist die Prüfsumme ein pragmatischer, gut vertretbarer Startpunkt.
  • Vertrauen in GitHub. Wer sein Release und sein Manifest bei GitHub hostet, vertraut GitHubs Zugriffskontrolle. Das ist dieselbe Vertrauensbasis wie beim Code-Hosting selbst — aber man sollte sie bewusst treffen (2FA, geschützte Branches, minimale Token-Rechte).
  • Das Manifest ist öffentlich. gh-pages ist öffentlich. Für ein kommerzielles Plugin mit Lizenzprüfung wäre dieser Weg so nicht geeignet — dann braucht es doch einen dynamischen Endpunkt, der Lizenzen prüft. Für frei verfügbare, nur eben nicht im Verzeichnis gelistete Plugins ist öffentlich genau richtig.

Diese Auslassungen sind Entscheidungen, keine Versäumnisse: Das Setup löst ein Problem — komfortable, rate-limit-freie, prüfsummen-gesicherte Auto-Updates für selbst gehostete Plugins — und zwar vollständig. Alles darüber hinaus wäre ein anderes Projekt.

FAQ

Muss ich für jedes neue Release Code anfassen?

Nein. Der komplette Ablauf hängt an einem einzigen Git-Tag. git tag v1.3.1 && git push --tags — den Rest (ZIP, Prüfsumme, Release, Manifest, Deploy) erledigt die Pipeline. Die Versionsnummer im Plugin-Header und im README.md pflegst du ohnehin; sie sind die Quelle, aus der Tag und Manifest sich speisen.

Funktioniert das auch für Themes statt Plugins?

Ja, das Prinzip ist identisch. WordPress hat für Themes das Gegenstück site_transient_update_themes und themes_api. Pipeline und Manifest bleiben praktisch gleich; nur die Hooks in der Updater-Klasse tauscht man gegen die Theme-Varianten.

Was passiert, wenn gh-pages mal nicht erreichbar ist?

Dann liefert get_remote_manifest() null zurück, und check_update() meldet schlicht kein Update — die Seite läuft normal weiter, sie erfährt in diesem Zyklus nur nichts von einer neuen Version. Beim nächsten Abruf (spätestens nach Ablauf des 24-Stunden-Cache) wird es erneut versucht. Ein Ausfall der Update-Quelle legt also nie die Seite lahm.

Ist die SHA256-Prüfung wirklich nötig, wenn alles über HTTPS läuft?

HTTPS sichert den Transportweg — es garantiert, dass niemand das ZIP unterwegs verändert. Es sagt aber nichts darüber, ob die Datei am Ursprung die richtige ist. Die Prüfsumme schließt genau diese Lücke: Sie bindet das installierte ZIP an genau den Fingerabdruck, den die Pipeline beim Bauen berechnet hat. Beide Ebenen ergänzen sich; keine ersetzt die andere.

Warum das README.md als Quelle fürs Manifest?

Weil es ohnehin gepflegt wird und WordPress-Konventionen (Beschreibung, Installation, Changelog, FAQ als ##-Abschnitte) nahe kommt. Ein Doppelpflegen von Beschreibungstexten — einmal fürs Repo, einmal fürs Backend — ist eine klassische Quelle für veraltete Angaben. Eine einzige Quelle verhindert das.

Weiterlesen

Der Denkansatz hinter diesem Artikel — ein Werkzeug selbst bauen und offenlegen, wie es wirklich funktioniert — steckt auch im Making-of meines SEO- und GEO-Tools. Wer die Sicherheits- und Automatisierungs-Perspektive vertiefen will, findet in Claude Code richtig konfigurieren den passenden Gegenpart zum Thema Hooks, Permissions und abgesicherte Automatisierung. Und am schnellsten verstehst du das Setup, indem du es an einem eigenen kleinen Plugin nachbaust: ein Tag, eine release.yml, ein Manifest — und der Aktualisieren-Button erscheint.