WordPress-Theme mit GitHub Actions und turbopress bereitstellen

WordPress-Theme mit GitHub Actions und turbopress bereitstellen

Diese Anleitung zeigt einen gezielten Code-Deploy für ein eigenes Theme. Die Pipeline baut und testet in GitHub Actions, verlangt vor der Übertragung ein belegtes WordPress-Backup, nutzt einen separaten SSH-Key und prüft die Website danach. Der gesamte Ablauf (Backup, Theme-Übertragung per rsync, Cache-Leeren und HTTPS-Prüfung) lief am 18.10.2026 in einer gehosteten GitHub-Actions-Pipeline gegen eine Staging-Site durch. Geprüft wurden auch die Fehlerfälle: Ein fehlgeschlagenes Backup stoppt die Pipeline vor der Übertragung, eine fehlgeschlagene HTTPS-Prüfung beendet den Job rot und lässt den Code auf dem Ziel liegen, und ein Rollback per git revert stellt den alten Stand wieder her. Teste ihn trotzdem zuerst gegen deine eigene Staging-Site, bevor du produktiv deployst. Direkt darunter steht ein kürzerer Schnellstart nur mit den API-Schritten.

Schnellstart: Backup und Cache-Leeren mit der CLI

Der kürzeste Weg ist die turbopress CLI in einem GitHub-Actions-Workflow. Dieser Workflow lief am 18.10.2026 in einem gehosteten GitHub-Runner gegen eine Staging-Site: CLI installieren, Sites lesen, Backup anstoßen und abwarten, Seiten-Cache leeren. Alle drei Schritte endeten mit succeeded.

Lege einen API-Token mit den Rechten sites:read, backups:create und cache:purge für deine Staging-Site an und hinterlege ihn als GitHub-Secret TP_API_TOKEN:

name: turbopress Hosting-API
on:
  workflow_dispatch:
permissions:
  contents: read
jobs:
  api:
    runs-on: ubuntu-latest
    env:
      TURBOPRESS_API_TOKEN: ${{ secrets.TP_API_TOKEN }}
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - name: CLI installieren
        run: npm install --global @turbopress-de/cli
      - name: Sites lesen
        run: |
          turbopress sites list --json > sites.json
          echo "SITE_ID=$(jq -r '.data[0].id' sites.json)" >> "$GITHUB_ENV"
      - name: Backup anstoßen und abwarten
        run: turbopress backups create --site "$SITE_ID" --wait
      - name: Cache leeren
        run: turbopress cache purge --site "$SITE_ID" --scope page --wait

Hat dein Token mehrere Sites, trage die site_id direkt ein, statt die erste zu nehmen. Der Schritt „Cache leeren“ endet auch dann erfolgreich, wenn auf der Site gar kein Seiten-Cache aktiv ist. Das Ergebnis des Jobs sagt dann ehrlich cache_cleared: false.

Voraussetzungen

  • Geeigneter WordPress-Tarif mit bestätigter SSH-Shell, remote ausführbarem rsync im betreffenden Account/CageFS und Schreibrecht im exakten Theme-/Plugin-Ziel. Reiner SFTP-Zugang reicht für diese Vorlage nicht. Dazu eine bekannte site_id aus GET /sites und ein API-Token mit backups:create und cache:purge für genau diese Site. Die Backupkorrelation muss für die Installation belegt sein. Fehlt eine dieser Voraussetzungen, wird dieses Verfahren nicht unterstützt.
  • Ein separater, möglichst auf das Ziel begrenzter SSH-Key. Hostkey-Fingerprint vorab über einen vertrauenswürdigen Kanal prüfen und als vollständige known_hosts-Zeile hinterlegen; ssh-keyscan allein beweist keine Identität.
  • CI-Build erzeugt genau ein Verzeichnis, hier build/theme. Der Scriptzielpfad ist nur themes/SLUG, plugins/SLUG oder mu-plugins/SLUG unter dem bestätigten wp-content-Pfad.
  • Das Verfahren zuerst auf Staging einschließlich absichtlich fehlschlagendem Deploy und Code-Rollback testen. Der Zielbaum darf während Pfadprüfung und Transfer nicht parallel durch denselben SSH-Account verändert werden; die Shell-Prüfung kann diesen Wettlauf nicht vollständig ausschließen.

Repository vorbereiten

Du brauchst zwei Dateien in deinem Projekt: die GitHub-Actions-Vorlage als .github/workflows/wordpress-staging.yml und das Deploy-Script als ci/deploy-wordpress.sh. Beide stehen direkt darunter zum Kopieren.

GitHub-Actions-Vorlage (.github/workflows/wordpress-staging.yml)

name: WordPress code deploy to staging

on:
  push:
    branches: [staging]
  workflow_dispatch:

permissions:
  contents: read

# One deployment per site, including manually restarted runs.
concurrency:
  group: wordpress-staging-${{ vars.WP_SITE_ID }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - name: Build and test in CI
        run: |
          npm ci
          npm test --if-present
          npm run build
          test -d build/theme
      - name: Install transfer and API tools
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends curl jq rsync openssh-client openssl ca-certificates
      - name: Verified backup, selected code transfer, cache purge and smoke
        env:
          TURBOPRESS_API_TOKEN: ${{ secrets.TURBOPRESS_API_TOKEN }}
          SSH_PRIVATE_KEY: ${{ secrets.WP_SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.WP_SSH_KNOWN_HOSTS }}
          TURBOPRESS_SSH_USER: ${{ vars.WP_SSH_USER }}
          TURBOPRESS_SSH_KEY_FILE: ${{ runner.temp }}/wp_id_ed25519
          TURBOPRESS_SSH_KNOWN_HOSTS: ${{ runner.temp }}/wp_known_hosts
          TURBOPRESS_WP_CONTENT_DIR: ${{ vars.WP_CONTENT_DIR }}
          TURBOPRESS_SMOKE_URL: ${{ vars.WP_SMOKE_URL }}
          TURBOPRESS_BACKUP_IDEMPOTENCY_KEY: gh-backup-${{ github.run_id }}-${{ github.run_attempt }}-${{ vars.WP_SITE_ID }}
          TURBOPRESS_CACHE_IDEMPOTENCY_KEY: gh-cache-${{ github.run_id }}-${{ github.run_attempt }}-${{ vars.WP_SITE_ID }}
          WP_SSH_HOST: ${{ vars.WP_SSH_HOST }}
          WP_SITE_ID: ${{ vars.WP_SITE_ID }}
          WP_CODE_TARGET: ${{ vars.WP_CODE_TARGET }}
        run: |
          set -euo pipefail
          set +x
          umask 077
          printf '%s\n' "$SSH_PRIVATE_KEY" > "$TURBOPRESS_SSH_KEY_FILE"
          printf '%s\n' "$SSH_KNOWN_HOSTS" > "$TURBOPRESS_SSH_KNOWN_HOSTS"
          trap 'rm -f "$TURBOPRESS_SSH_KEY_FILE" "$TURBOPRESS_SSH_KNOWN_HOSTS"' EXIT
          bash ci/deploy-wordpress.sh "$WP_SSH_HOST" "$WP_SITE_ID" build/theme "$WP_CODE_TARGET"

Deploy-Script (ci/deploy-wordpress.sh)

#!/usr/bin/env bash
# Deploy one built WordPress theme, plugin or MU-plugin directory after a verified backup.
set -euo pipefail
set +x

die() { printf 'WordPress deploy: %s\n' "$1" >&2; exit 1; }
[[ $# -eq 4 ]] || die 'Usage: deploy-wordpress.sh HOST SITE_ID BUILD_DIR themes|plugins|mu-plugins/SLUG'
host=$1
site=$2
build=$3
target=$4
api='https://panel.turbopress.de/api/v1'

[[ $host =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ && $host != *..* ]] || die 'Invalid SSH host'
[[ $site =~ ^[A-Za-z0-9_-]{1,128}$ ]] || die 'Invalid site ID'
[[ $target =~ ^(themes|plugins|mu-plugins)/[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die 'Target must be one selected theme or plugin directory'
[[ -d $build && ! -L $build ]] || die 'Build directory is missing or linked'

: "${TURBOPRESS_API_TOKEN:?TURBOPRESS_API_TOKEN is required}"
: "${TURBOPRESS_SSH_USER:?TURBOPRESS_SSH_USER is required}"
: "${TURBOPRESS_SSH_KEY_FILE:?TURBOPRESS_SSH_KEY_FILE is required}"
: "${TURBOPRESS_SSH_KNOWN_HOSTS:?TURBOPRESS_SSH_KNOWN_HOSTS is required}"
: "${TURBOPRESS_WP_CONTENT_DIR:?TURBOPRESS_WP_CONTENT_DIR is required}"
: "${TURBOPRESS_SMOKE_URL:?TURBOPRESS_SMOKE_URL is required}"
: "${TURBOPRESS_BACKUP_IDEMPOTENCY_KEY:?TURBOPRESS_BACKUP_IDEMPOTENCY_KEY is required}"
: "${TURBOPRESS_CACHE_IDEMPOTENCY_KEY:?TURBOPRESS_CACHE_IDEMPOTENCY_KEY is required}"
[[ $TURBOPRESS_API_TOKEN =~ ^tpapi_[A-Za-z0-9_-]+$ ]] || die 'Invalid API token format'
[[ $TURBOPRESS_SSH_USER =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || die 'Invalid SSH user'
[[ $TURBOPRESS_SSH_KEY_FILE =~ ^[A-Za-z0-9_./-]+$ && -r $TURBOPRESS_SSH_KEY_FILE ]] || die 'SSH key path is invalid or unreadable'
[[ $TURBOPRESS_SSH_KNOWN_HOSTS =~ ^[A-Za-z0-9_./-]+$ && -s $TURBOPRESS_SSH_KNOWN_HOSTS ]] || die 'Pinned known_hosts file is missing or empty'
[[ $TURBOPRESS_SMOKE_URL =~ ^https://[A-Za-z0-9.-]+(/[A-Za-z0-9_./?&=%-]*)?$ ]] || die 'Smoke URL must be HTTPS without credentials'
[[ $TURBOPRESS_WP_CONTENT_DIR =~ ^(/[A-Za-z0-9_.-]+)+/wp-content$ ]] || die 'Remote base must be an absolute wp-content path'
[[ /$TURBOPRESS_WP_CONTENT_DIR/ != *'/../'* && /$TURBOPRESS_WP_CONTENT_DIR/ != *'/./'* ]] || die 'Invalid wp-content directory component'
for key in "$TURBOPRESS_BACKUP_IDEMPOTENCY_KEY" "$TURBOPRESS_CACHE_IDEMPOTENCY_KEY"; do
  [[ $key =~ ^[A-Za-z0-9_-]{16,128}$ ]] || die 'Invalid idempotency key'
done
[[ $TURBOPRESS_BACKUP_IDEMPOTENCY_KEY != "$TURBOPRESS_CACHE_IDEMPOTENCY_KEY" ]] || die 'Backup and cache keys must differ'
for tool in curl jq rsync ssh find date sleep openssl; do command -v "$tool" >/dev/null || die "Missing tool: $tool"; done

# The configured values identify the CI attempt; each invocation gets fresh
# actual header keys so a second transfer cannot reuse an old backup/purge.
attempt_nonce=$(openssl rand -hex 16) || die 'Could not create deployment attempt nonce'
[[ $attempt_nonce =~ ^[a-f0-9]{32}$ ]] || die 'Invalid deployment attempt nonce'
derive_key() {
  local digest hash
  digest=$(printf '%s:%s:%s' "$1" "$attempt_nonce" "$2" | openssl dgst -sha256 -r) || die 'Could not derive idempotency key'
  hash=${digest%% *}
  [[ $hash =~ ^[a-f0-9]{64}$ ]] || die 'Invalid derived idempotency key'
  printf 'tpci-%s-%s\n' "$2" "$hash"
}
backup_key=$(derive_key "$TURBOPRESS_BACKUP_IDEMPOTENCY_KEY" backup)
cache_key=$(derive_key "$TURBOPRESS_CACHE_IDEMPOTENCY_KEY" cache)

# The selected source is a build artifact, never the WordPress document root.
forbidden=$(find "$build" \( -type l -o -name wp-config.php -o -name wp-admin -o -name wp-includes -o -name uploads -o -name '*.sql' -o -name '*.sql.gz' -o -name '*.sqlite' -o -name '*.db' -o -name .env \) -print -quit)
[[ -z $forbidden ]] || die 'Build contains a symlink or WordPress data/config file'

api_request() {
  local method=$1 url=$2 idempotency_key=${3:-} payload=${4:-}
  local args=(-q --silent --show-error --fail --max-time 30 --retry 0 --request "$method" --config -)
  if [[ -n $payload ]]; then args+=(--header 'Content-Type: application/json' --data "$payload"); fi
  # Config travels on stdin, so the API token is absent from curl's argv and logs.
  {
    printf 'header = "Authorization: Bearer %s"\n' "$TURBOPRESS_API_TOKEN"
    if [[ -n $idempotency_key ]]; then printf 'header = "Idempotency-Key: %s"\n' "$idempotency_key"; fi
  } | curl "${args[@]}" "$url"
}

operation_id() {
  jq -er '.data.id | select(type == "string" and test("^[A-Za-z0-9_-]{1,128}$"))'
}

wait_operation() {
  local id=$1 kind=$2 response status now deadline
  deadline=$(( $(date +%s) + 600 ))
  while :; do
    now=$(date +%s)
    (( now < deadline )) || die "Operation $id timed out; inspect it before retrying"
    response=$(api_request GET "$api/operations/$id") || die "Could not read operation $id; outcome unknown"
    status=$(jq -er --arg id "$id" --arg site "$site" --arg kind "$kind" \
      'select(.data.id == $id and .data.site_id == $site and .data.kind == $kind) | .data.status | select(type == "string")' <<< "$response") \
      || die "Operation $id has invalid identity or status"
    now=$(date +%s)
    (( now < deadline )) || die "Operation $id timed out; inspect it before retrying"
    case "$status" in
      succeeded) return 0 ;;
      failed|unknown) die "Operation $id ended as $status; no deployment continues" ;;
      queued|running) ;;
      *) die "Operation $id returned invalid status" ;;
    esac
    (( deadline - now >= 5 )) || die "Operation $id timed out; inspect it before retrying"
    sleep 5
  done
}

printf 'backup_idempotency_key=%s\n' "$backup_key" >&2
backup_response=$(api_request POST "$api/sites/$site/backups" "$backup_key") \
  || die "Backup request outcome unknown; idempotency_key=$backup_key; resolve before a new invocation"
backup_id=$(operation_id <<< "$backup_response") || die "Backup response has no valid operation ID; idempotency_key=$backup_key"
printf 'backup_operation_id=%s\n' "$backup_id" >&2
wait_operation "$backup_id" 'backups:create'

ssh_options="ssh -i $TURBOPRESS_SSH_KEY_FILE -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$TURBOPRESS_SSH_KNOWN_HOSTS -o BatchMode=yes"
remote_path="$TURBOPRESS_WP_CONTENT_DIR/$target"
# Refuse links in the existing remote path. The SSH account must not mutate this
# tree concurrently; portable shell checks cannot make the subsequent rsync atomic.
ssh -i "$TURBOPRESS_SSH_KEY_FILE" -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \
  -o "UserKnownHostsFile=$TURBOPRESS_SSH_KNOWN_HOSTS" -o BatchMode=yes \
  "$TURBOPRESS_SSH_USER@$host" sh -s -- "$remote_path" <<'REMOTE_CHECK' \
  || die 'Remote target is missing, linked, or not a directory'
set -eu
remaining=${1#/}
current=
while [ -n "$remaining" ]; do
  component=${remaining%%/*}
  case "$remaining" in */*) remaining=${remaining#*/} ;; *) remaining= ;; esac
  [ -n "$component" ] || exit 1
  current=$current/$component
  [ ! -L "$current" ] || exit 1
  if [ -n "$remaining" ]; then
    [ -d "$current" ] || exit 1
  elif [ -e "$current" ]; then
    [ -d "$current" ] || exit 1
  fi
done
REMOTE_CHECK
rsync -rt --no-links -e "$ssh_options" -- "$build/" "$TURBOPRESS_SSH_USER@$host:$TURBOPRESS_WP_CONTENT_DIR/$target/" \
  || die 'Code transfer failed; inspect the selected directory'

printf 'cache_idempotency_key=%s\n' "$cache_key" >&2
cache_response=$(api_request POST "$api/sites/$site/cache/purge" "$cache_key" '{"scope":"all"}') \
  || die "Cache purge outcome unknown; idempotency_key=$cache_key; code is transferred, inspect cache state"
cache_id=$(operation_id <<< "$cache_response") || die "Cache purge response has no valid operation ID; idempotency_key=$cache_key"
printf 'cache_operation_id=%s\n' "$cache_id" >&2
wait_operation "$cache_id" 'cache:purge'

smoke_status=$(curl -q --silent --show-error --fail --max-time 15 --retry 0 --output /dev/null \
  --write-out '%{http_code}' "$TURBOPRESS_SMOKE_URL" </dev/null) \
  || die 'HTTP smoke test failed after cache purge; code remains on the target for manual inspection or code rollback'
[[ $smoke_status == 200 ]] || die 'HTTP smoke test did not return direct 200 after cache purge'
printf 'WordPress code deploy verified; backup=%s cache=%s\n' "$backup_id" "$cache_id"

Prüfe beide Dateien bei jeder Aktualisierung gemeinsam. Die Vorlage läuft auf staging, installiert Build- und Transferwerkzeuge im CI-Runner und serialisiert Runs je Site mit einer Concurrency-Gruppe. Node, Composer und Buildwerkzeuge sind nur im Runner nötig; rsync muss auch auf dem Zielhost über SSH ausführbar sein.

Setze GitHub-Secrets TURBOPRESS_API_TOKEN, WP_SSH_PRIVATE_KEY und WP_SSH_KNOWN_HOSTS. Setze GitHub-Variables WP_SSH_HOST, WP_SSH_USER, WP_SITE_ID, WP_CODE_TARGET (z. B. themes/mein-theme), WP_CONTENT_DIR (absoluter Pfad bis wp-content) und WP_SMOKE_URL (HTTPS). Benutze ein staging-Environment; wenn mehrere Sites existieren, pro Site eigene Variablen/Concurrency-Gruppe konfigurieren. API-Token und SSH-Key nie in Git oder Buildartefakte schreiben.

Ablauf und Prüfung

Die Vorlage führt npm ci, Tests und Build im Runner aus. Das Script fordert mit innerhalb eines Versuchs stabilem Idempotency-Key ein Backup an und wartet auf succeeded; failed, unknown und Timeout stoppen die Übertragung. Vor dem Transfer prüft es die vorhandenen Remote-Pfadkomponenten auf Symlinks und Verzeichnistyp; danach überträgt rsync ausschließlich das ausgewählte Buildverzeichnis, ohne globales --delete. Das Script fordert Cache-Purge an, wartet auf dessen belegten Abschluss und führt erst dann einen HTTPS-Smoke-Test ohne Redirect mit erwartetem HTTP 200 aus. Bei dessen Fehlschlag endet der Job ungleich null; bereits übertragener Code bleibt bis zur manuellen Prüfung oder zum separaten Code-Rollback liegen.

Ein erfolgreicher Workflow muss im Log Backup- und Cache-Operations-IDs ohne Token/SSH-Key zeigen; zusätzlich die getestete Seite prüfen. Ein Test mit erzwungenem Backupfehler muss vor rsync abbrechen. Datenbank, Uploads, wp-config.php, WordPress-Core, Symlinks und SQL-Dateien gehören nicht in das Buildverzeichnis. Ein Code-Rollback setzt keine Datenbankmigration eines Plugins zurück.

Rollback und Backup-Limit

Für einen Rollback nimmst du den fehlerhaften Commit mit git revert zurück und pushst. Die Pipeline läuft erneut mit einem frischen Backup und überträgt den alten Stand. Dabei wird nur das gewählte Theme- oder Plugin-Verzeichnis ersetzt, eine Datenbank wird nicht zurückgesetzt.

Jede Pipeline-Ausführung legt ein Backup an. Hat die Installation ihr Backup-Limit erreicht (bei einer Staging-Installation sind es zehn Backups), antwortet die API mit backup_limit_reached, und die Pipeline bricht vor der Übertragung ab. Entferne dann in Plesk unter WordPress Toolkit ein altes Backup und starte den Job neu. Dasselbe gilt, wenn die API für das Backup ein 403 liefert, weil dem Token das Recht backups:create fehlt: Auch dann wird nichts übertragen.

Fehler und Widerruf

Bei unknown oder Timeout Jobstatus und Backendzustand klären, keinen neuen Key blind erzeugen. Die Vorlage verwendet github.run_id und github.run_attempt als nachverfolgbare Key-Basis und ergänzt bei jedem neuen Scriptaufruf einen frischen Zufallswert; selbst ein Rerun desselben Jobs ist daher keine automatische Fortsetzung. Für die manuelle Klärung die protokollierte tatsächlich gesendete Idempotency-Key-Kennung und die bekannte Operations-ID verwenden. Innerhalb von 24 Stunden kann nur derselbe alte Request mit exakt diesem alten Key geklärt werden; danach kann selbst der alte Key neu wirken. Bei Schlüsselverlust sowohl API-Token im Panel als auch SSH-Key am Hostingzugang separat widerrufen. Siehe Fehlersuche und Tokenverwaltung.

  • 0 Benutzer fanden dies hilfreich
War diese Antwort hilfreich?