WordPress-Theme mit GitLab CI und turbopress bereitstellen
Diese Anleitung beschreibt einen gezielten Theme-Code-Deploy aus GitLab CI. Das Beispiel fordert vor der Übertragung ein belegtes Backup über die Hosting-API an und nutzt für den Code einen separaten SSH-Key. Das Beispiel wurde lokal mit Testdoubles geprüft, aber nicht in einem gehosteten GitLab-Lauf. Teste es zuerst gegen deine eigene Staging-Site, bevor du produktiv deployst.
Voraussetzungen
Du brauchst einen passenden WordPress-Tarif mit bestätigter SSH-Shell, remote ausführbarem rsync im betreffenden Account/CageFS, Schreibrecht im exakten Theme-/Plugin-Ziel, site_id, einem API-Token mit backups:create und cache:purge für genau diese Site sowie einer überprüften Backupkorrelation. Reiner SFTP-Zugang genügt nicht; ohne diese Host-/Tarifvoraussetzungen wird das Verfahren nicht unterstützt. Prüfe den SSH-Hostkey unabhängig und speichere die komplette known_hosts-Zeile. Das CI-Projekt muss aus seinem Build ein einzelnes build/theme-Verzeichnis erzeugen.
CI einrichten
Du brauchst zwei Dateien in deinem Projekt: die GitLab-Vorlage als .gitlab-ci.yml und das Deploy-Script als ci/deploy-wordpress.sh. Beide stehen direkt darunter zum Kopieren.
GitLab-Vorlage (.gitlab-ci.yml)
stages: [build, deploy]
build_wordpress_code:
image: node:22-bookworm
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
script:
- |
# Fail before npm lifecycle scripts if deployment secrets were scoped to *.
if [ -n "${WP_API_TOKEN+x}" ] || [ -n "${WP_SSH_PRIVATE_KEY+x}" ] || [ -n "${WP_SSH_KNOWN_HOSTS+x}" ]; then
echo 'Deployment credentials must not be available to the build job' >&2
exit 1
fi
- npm ci
- npm test --if-present
- npm run build
- test -d build/theme
artifacts:
paths: [build/theme/]
expire_in: 1 day
deploy_wordpress_staging:
image: debian:bookworm-slim
stage: deploy
needs:
- job: build_wordpress_code
artifacts: true
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
environment: staging
# Scope all three deploy secrets to environment "staging", never the default "*".
# Use a distinct literal resource_group for each site when cloning this job.
resource_group: wordpress-staging-site-1
before_script:
- apt-get update
- apt-get install -y --no-install-recommends curl jq rsync openssh-client openssl ca-certificates
script:
- |
set -euo pipefail
set +x
# WP_SSH_PRIVATE_KEY and WP_SSH_KNOWN_HOSTS are protected File-type variables.
export TURBOPRESS_API_TOKEN="$WP_API_TOKEN"
export TURBOPRESS_SSH_USER="$WP_SSH_USER"
export TURBOPRESS_SSH_KEY_FILE="$WP_SSH_PRIVATE_KEY"
export TURBOPRESS_SSH_KNOWN_HOSTS="$WP_SSH_KNOWN_HOSTS"
export TURBOPRESS_WP_CONTENT_DIR="$WP_CONTENT_DIR"
export TURBOPRESS_SMOKE_URL="$WP_SMOKE_URL"
export TURBOPRESS_BACKUP_IDEMPOTENCY_KEY="gl-backup-$CI_PIPELINE_ID-$CI_JOB_ID-$WP_SITE_ID"
export TURBOPRESS_CACHE_IDEMPOTENCY_KEY="gl-cache-$CI_PIPELINE_ID-$CI_JOB_ID-$WP_SITE_ID"
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"
Hinweis: Die Vorlage baut/testet in node:22-bookworm; der Deployjob installiert Transferwerkzeuge nur im Runner. Passe resource_group für jede Site auf einen eigenen festen Wert an, damit parallele Deployments derselben Site serialisiert werden.
Lege WP_API_TOKEN als maskierte, geschützte Variable an. WP_SSH_PRIVATE_KEY und WP_SSH_KNOWN_HOSTS sind geschützte File-Variablen; ihr Inhalt darf nicht ins Log. Setze für alle drei den Environment-Scope ausdrücklich auf staging, nicht auf *. Der Build-Job hat kein Environment und bricht vor npm ci ab, falls diese Deploy-Variablen dort doch verfügbar sind. Weitere Variablen: WP_SSH_HOST, WP_SSH_USER, WP_SITE_ID, WP_CODE_TARGET (z. B. themes/mein-theme), WP_CONTENT_DIR (absolut bis wp-content) und WP_SMOKE_URL (direkte HTTPS-URL mit HTTP 200). Schütze den staging-Branch, das Environment und dessen Deploymentrechte.
Prüfen
Die Pipeline führt Build und Tests im Runner aus, wartet auf backups:create → succeeded, prüft die vorhandenen Remote-Pfadkomponenten auf Symlinks/Verzeichnistyp und überträgt dann nur das gewählte Theme-Verzeichnis per rsync. Der Zielbaum darf während Prüfung und Transfer nicht parallel durch denselben SSH-Account verändert werden; die Shell-Prüfung schließt diesen Wettlauf nicht vollständig aus. Danach wartet die Pipeline auf cache:purge → succeeded und prüft zuletzt die HTTPS-Seite ohne Redirect auf HTTP 200. Bei failed, unknown oder Timeout vor der Übertragung stoppt sie. Ein fehlerhafter Smoke-Test liefert Exitcode ungleich null; den bereits übertragenen Code musst du untersuchen oder separat zurückrollen.
Führe vor einer Freigabe auf Staging bewusst einen Backupfehler und einen fehlgeschlagenen Deploy durch. Prüfe, dass bei Backupfehler kein Transfer stattfindet und dass Beiträge, Bestellungen, Benutzer, Uploads sowie wp-config.php unverändert bleiben. Das Script verweigert Symlinks, SQL-Dateien, Core- und Uploadverzeichnisse im Build und verwendet kein globales --delete.
Wiederaufnahme und Zugang sperren
Bei unklarem Ergebnis zuerst den vorhandenen Job lesen. Die Vorlage verwendet CI_PIPELINE_ID und CI_JOB_ID als Key-Basis plus einen frischen Zufallswert bei jedem neuen Scriptaufruf. Auch ein Job-Retry innerhalb derselben Pipeline ist keine automatische Fortsetzung. Starte bei unknown nicht einfach einen Retry. Für manuelle Klärung die protokollierte tatsächlich gesendete Key-Kennung und die bekannte Operations-ID verwenden. Innerhalb von 24 Stunden kann nur derselbe alte Request mit genau diesem alten Key wiederholt werden; danach kann selbst dieser Key neu wirken. Ein API-Tokenwiderruf sperrt den separaten SSH-Key nicht; widerrufe beide bei Verdacht auf Verlust. Mehr dazu: Fehlersuche und API-Tokens.