# 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.

Source: https://www.jpkc.com/db/blog/wordpress-plugin-auto-updates-github-actions/

Wer ein WordPress-Plugin schreibt, das nicht im offiziellen [wordpress.org](https://de.wordpress.org/plugins/)-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:

```text
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".

```yaml
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:

```bash
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:

```bash
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):

```bash
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:

```yaml
- 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:

```python
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:

```yaml
- 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:

```text
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:

```json
{
  "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.

```php
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_plugins`** → `check_update()`: meldet WordPress, dass ein Update vorliegt.
- **`plugins_api`** → `plugin_info()`: füllt das „Details ansehen"-Modal aus dem Manifest.
- **`upgrader_pre_download`** → `verify_download_checksum()`: prüft die Prüfsumme, *bevor* installiert wird.
- **`upgrader_process_complete`** → `clear_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:

```php
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.

```php
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:

```php
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:

```php
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

```text
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](https://www.sigstore.dev/) / `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](https://www.jpkc.com/db/blog/making-of-seo-tool/). Wer die Sicherheits- und Automatisierungs-Perspektive vertiefen will, findet in [Claude Code richtig konfigurieren](https://www.jpkc.com/db/blog/claude-code-konfiguration/) 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.

