Deployment and CI/CD

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

Deploying WordPress moves two things in opposite directions: code (core, plugins, themes, configuration) travels up from Git to production through automated checks, and content (database and uploads) travels only down from production. For developers and operators of the Lumen Press site, this page builds a GitHub Actions pipeline for the lumen-catalog plugin and the lumen theme, deploys it with an atomic symlink swap, and covers database and media sync, maintenance mode and rollback.

The page was written against WordPress 7.1.3 (released 2026-10-06), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0, @wordpress/env 12.0.0, WPCS 3.4.1 and WP-CLI 2.12.0, and checked on 2026-10-28. On that day WordPress 7.1.4 shipped as a security-only release (fixes in the upgrade process, the Interactivity API and several stored XSS issues; no API changes); 6.9.11 is the current 6.9 release. The pipeline and the release pin 7.1.4. Tests ran against 7.1.4 and 6.9.11 in wp-env; the release script ran on the wordpress:7.1.3-php8.4-apache image (7.1.4 images were not yet published) serving a 7.1.4 release.

Environments and WP_ENVIRONMENT_TYPE

Lumen Press has the three environments described in Local Development Environments: local (wp-env or Docker Compose at lumenpress.test), staging (staging.lumenpress.example, a recent copy of production on the production stack) and production (lumenpress.example). Each declares what it is with WP_ENVIRONMENT_TYPE, which wp_get_environment_type() (5.5) returns as local, development, staging or production. The constant wins over an environment variable of the same name, a missing or unknown value means production, and development turns WP_DEBUG on when it is not defined.

Local Staging Production

WP_ENVIRONMENT_TYPE

local

staging

production

Code arrives by

Working copy, wp-env mappings

The pipeline, after every push to main

The pipeline, after approval of the staging deploy

Database

Disposable, or a sanitised copy

Sanitised copy of production, refreshed on demand

Source of truth, never overwritten

Uploads

Partial copy or proxied from production

Copy, or served from production

Source of truth, backed up

Mail, indexing, cron

Suppressed

Suppressed, noindex, distributor sync off

On

File modifications

Allowed

DISALLOW_FILE_MODS

DISALLOW_FILE_MODS

Code branches on the environment type, never on the host name. The value belongs in the server configuration or wp-config.php, never in a plugin, because the result is cached on the first call.

Code and content

What Where it lives How it moves

lumen-catalog, lumen, lumen-child, must-use plugins

Git

Pipeline: build, test, deploy

Third-party plugins and themes, WordPress core

Pinned versions (Composer or the release script)

Pipeline, as part of the release

wp-config.php structure

Git (deploy/wp-config.php)

Pipeline

Database credentials, keys and salts, WP_HOME

Server only (shared/config.php) or the platform’s secret store

Never in Git, never in CI logs

Posts, users, options, orders, comments

Database

Down only: production to staging to local

wp-content/uploads

Shared disk or object storage

Down only, or proxied

build/, vendor/, node_modules/

Generated

Built by CI, never committed

Site Editor changes to templates and global styles live in the database, so a theme change made in production is exported to files and committed (see Create Block Theme and Version Control). And DISALLOW_FILE_MODS removes the installers, editors and automatic updates of plugins, themes and core, so that Git keeps describing what runs; security releases such as 7.1.4 then arrive only through a deploy (see Updates and Auto-Updates). Composer-managed core and plugins are covered in Composer-Based WordPress.

The pipeline

flowchart LR PR["Push or pull request"] --> B["blocks: npm ci, lint-js, lint-style, wp-scripts build"] PR --> Q["php-quality: PHPCS and PHPStan for plugin and theme"] B --> U["tests: PHPUnit for PHP 7.4 and 8.4 by WP 6.9.11 and 7.1.4 on wp-env, Playwright on 8.4 and 7.1.4"] Q --> P["package: core zip, composer without dev, release artifact"] U --> P P -- "push to main only" --> S["deploy to staging"] S -- "required reviewer approves" --> D["deploy to production"]

Figure: pull requests run the check jobs; a push to main also packages a release and deploys it to staging, then to production once a reviewer approves the production environment.

The repository holds wp-content/plugins/lumen-catalog, wp-content/themes/lumen, wp-content/mu-plugins and a deploy/ folder. The plugin’s package.json pins @wordpress/scripts 37.0.0, @wordpress/env 12.0.0, @wordpress/e2e-test-utils-playwright 3.1.0 and @playwright/test 1.64.1, and defines the scripts the workflow calls: build (the wp-scripts build command with --blocks-manifest shown in block.json and Registration, writing to build/blocks/), lint:js, lint:style, test:e2e (wp-scripts test-playwright) and wp-env.

The wp-env configuration pins the database and activates the theme after every start. testsEnvironment is deprecated in wp-env 12 but still defaults to creating a second set of containers on port 8889, so it is switched off; PHPUnit runs in the cli container, which ships the WordPress test library for the installed version in WP_TESTS_DIR:

{
	"core": "https://downloads.wordpress.org/release/wordpress-7.1.4.zip",
	"phpVersion": "8.4",
	"mariadbVersion": "11.8",
	"testsEnvironment": false,
	"plugins": [ "." ],
	"themes": [ "../../themes/lumen" ],
	"config": {
		"WP_ENVIRONMENT_TYPE": "local"
	},
	"lifecycleScripts": {
		"afterStart": "wp-env run cli wp theme activate lumen"
	}
}

Without afterStart the fresh site had no usable theme ("The theme directory "default" does not exist") and the end-to-end test failed. phpcs.xml.dist and phpstan.neon.dist are explained in Coding Standards and Static Analysis, the PHPUnit bootstrap and Playwright fixtures in Testing.

The CI workflow

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  PLUGIN_DIR: wp-content/plugins/lumen-catalog
  WP_RELEASE: '7.1.4'

jobs:
  blocks:
    name: Lint and build blocks
    runs-on: ubuntu-24.04
    defaults:
      run:
        working-directory: wp-content/plugins/lumen-catalog
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/setup-node@v7.1.0
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: wp-content/plugins/lumen-catalog/package-lock.json
      - run: npm ci
      - run: npm run lint:js
      - run: npm run lint:style
      - run: npm run build
      - uses: actions/upload-artifact@v7.0.2
        with:
          name: blocks-build
          path: wp-content/plugins/lumen-catalog/build/
          retention-days: 7

  php-quality:
    name: PHPCS and PHPStan (${{ matrix.project }})
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        project: [plugins/lumen-catalog, themes/lumen]
    defaults:
      run:
        working-directory: wp-content/${{ matrix.project }}
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: shivammathur/setup-php@2.40.0
        with:
          php-version: '8.4'
          tools: composer:v2, cs2pr
          coverage: none
      - run: composer install --no-interaction --no-progress
      - run: vendor/bin/phpcs -q --report=checkstyle | cs2pr
      - if: matrix.project == 'plugins/lumen-catalog'
        run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress

  tests:
    name: Tests (PHP ${{ matrix.php }}, WP ${{ matrix.wp }})
    needs: blocks
    runs-on: ubuntu-24.04
    strategy:
      fail-fast: false
      matrix:
        php: ['7.4', '8.4']
        wp: ['6.9.11', '7.1.4']
    env:
      WP_ENV_PHP_VERSION: ${{ matrix.php }}
      WP_ENV_CORE: https://downloads.wordpress.org/release/wordpress-${{ matrix.wp }}.zip
    defaults:
      run:
        working-directory: wp-content/plugins/lumen-catalog
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/setup-node@v7.1.0
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: wp-content/plugins/lumen-catalog/package-lock.json
      - uses: shivammathur/setup-php@2.40.0
        with:
          php-version: '8.4'
          tools: composer:v2
          coverage: none
      - uses: actions/download-artifact@v8.0.2
        with:
          name: blocks-build
          path: wp-content/plugins/lumen-catalog/build/
      - run: npm ci
      - run: composer install --no-interaction --no-progress
      - run: npm run wp-env start
      - run: npm run wp-env -- run cli --env-cwd=wp-content/plugins/lumen-catalog vendor/bin/phpunit
      - name: Playwright end-to-end tests (production versions only)
        if: matrix.php == '8.4' && matrix.wp == '7.1.4'
        env:
          WP_BASE_URL: http://localhost:8888
        run: |
          npx playwright install --with-deps chromium
          npm run test:e2e
      - if: failure()
        uses: actions/upload-artifact@v7.0.2
        with:
          name: playwright-${{ matrix.php }}-${{ matrix.wp }}
          path: wp-content/plugins/lumen-catalog/artifacts/
          if-no-files-found: ignore
          retention-days: 7

  package:
    name: Assemble the release
    needs: [php-quality, tests]
    if: github.event_name == 'push'
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: shivammathur/setup-php@2.40.0
        with:
          php-version: '8.4'
          tools: composer:v2
          coverage: none
      - uses: actions/download-artifact@v8.0.2
        with:
          name: blocks-build
          path: ${{ env.PLUGIN_DIR }}/build/
      - name: Download WordPress core and check its checksum
        run: |
          zip="wordpress-${WP_RELEASE}-no-content.zip"
          curl -fsSLO "https://downloads.wordpress.org/release/${zip}"
          echo "$(curl -fsSL "https://downloads.wordpress.org/release/${zip}.sha1")  ${zip}" | sha1sum -c -
          unzip -q "${zip}" -d core
      - name: Build the release directory
        run: |
          composer install --working-dir="${PLUGIN_DIR}" --no-dev --optimize-autoloader --no-interaction --no-progress
          mv core/wordpress release
          mkdir -p release/wp-content/plugins release/wp-content/themes release/wp-content/mu-plugins
          rsync -a --exclude-from="${PLUGIN_DIR}/.distignore" "${PLUGIN_DIR}/" release/wp-content/plugins/lumen-catalog/
          rsync -a --exclude-from=wp-content/themes/lumen/.distignore wp-content/themes/lumen/ release/wp-content/themes/lumen/
          rsync -a wp-content/mu-plugins/ release/wp-content/mu-plugins/
          cp deploy/wp-config.php release/wp-config.php
          echo "${GITHUB_SHA}" > release/REVISION
      - uses: actions/upload-artifact@v7.0.2
        with:
          name: release
          path: release/
          include-hidden-files: true
          retention-days: 30

  staging:
    needs: package
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
    secrets: inherit

  production:
    needs: staging
    uses: ./.github/workflows/deploy.yml
    with:
      environment: production
    secrets: inherit
  • Build once, test the build. Every later job downloads the blocks-build artifact, so tests and release use the same JavaScript. It must contain build/blocks/blocks-manifest.php, or the blocks are never registered (a build without --output-path=build/blocks wrote it one folder up and the registration test failed).

  • The matrix. WP_ENV_PHP_VERSION and WP_ENV_CORE override phpVersion and core from .wp-env.json. 7.4 and 6.9 are the plugin’s declared minimums; 8.4 and 7.1.4 are what production runs. Both corners were run locally (PHP 7.4.33 with 6.9.11, PHP 8.4.27 with 7.1.4; OK, 2 tests, 3 assertions). 8.4.27 is a release the Hosting Handbook warns about; production should run 8.4.28 or later.

  • Composer. config.platform.php 7.4.33 resolves packages that load on PHP 7.4 (PHPUnit 9.6) on an 8.4 runner.

  • End-to-end. Playwright runs only in the production cell, reusing its site. WP_BASE_URL points the @wordpress/scripts Playwright configuration (default 8889, the old tests site) at 8888. The test inserts lumen/book-card titled "The Lighthouse Ledger", publishes and checks the front end (passed in 3.6 s).

  • Hardening. Actions are pinned to release tags (a commit SHA is stronger, since tags can move); the token is read-only.

The workflow and deploy.yml passed actionlint 1.7.12 (including shellcheck). Every step was run locally with the same commands; the workflow itself was not run on GitHub.

The deploy workflow

Deployment is a reusable workflow called once per environment. Each call names a GitHub environment, which holds that environment’s secrets and variables and its protection rules: production has a required reviewer, so the run pauses after staging until someone approves it, and both allow deployments only from main.

name: Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string

permissions:
  contents: read

jobs:
  deploy:
    name: Deploy to ${{ inputs.environment }}
    runs-on: ubuntu-24.04
    environment:
      name: ${{ inputs.environment }}
      url: ${{ vars.SITE_URL }}
    concurrency:
      group: deploy-${{ inputs.environment }}
      cancel-in-progress: false
    env:
      DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
      DEPLOY_USER: ${{ vars.DEPLOY_USER }}
      RELEASE: r${{ github.run_number }}-${{ github.sha }}
    steps:
      - uses: actions/download-artifact@v8.0.2
        with:
          name: release
          path: release/
      - name: Configure SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "${SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
          printf '%s\n' "${SSH_PRIVATE_KEY}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
      - name: Upload the release
        run: |
          rsync -az --delete -e 'ssh -i ~/.ssh/deploy_key' release/ \
            "${DEPLOY_USER}@${DEPLOY_HOST}:/srv/lumenpress/releases/${RELEASE}/"
      - name: Activate the release
        run: |
          ssh -i ~/.ssh/deploy_key "${DEPLOY_USER}@${DEPLOY_HOST}" \
            "/srv/lumenpress/bin/activate-release ${RELEASE}"
      - name: Smoke test
        env:
          SITE_URL: ${{ vars.SITE_URL }}
        run: curl -fsS -o /dev/null --retry 3 "${SITE_URL}/wp-json/"

Per environment, DEPLOY_SSH_KEY is a secret (a key pair used only for deployments, its public half in the deploy user’s authorized_keys); DEPLOY_HOST, DEPLOY_USER, DEPLOY_KNOWN_HOSTS (ssh-keyscan output, checked by hand once) and SITE_URL are variables. secrets: inherit passes the caller’s secrets on, and the job’s environment: selects whose values apply. Secrets are masked in logs but readable by every step of the job, so pull requests never run a job with an environment. Expressions reach scripts through env:, never pasted into run:, which keeps them out of shell parsing. The deploy user owns /srv/lumenpress but has no sudo and no database root access. The release id combines the run number and the commit SHA, which REVISION in the docroot also records (block that path at the web server if it should stay private).

Deploying a container image instead

On a container platform (Kubernetes, a managed container service) the release directory becomes an image. The same package artifact is copied over the official image, which provides Apache, PHP 8.4 and its extensions; because the docroot is already populated, the image’s entrypoint does not copy its own WordPress over it:

FROM wordpress:7.1.3-php8.4-apache
# Core 7.1.4, plugins, theme and wp-config.php all come from the release directory.
COPY --chown=www-data:www-data release/ /var/www/html/

The image built from the release reported WordPress 7.1.4 and the expected REVISION. The deploy job then uses docker/login-action@v4.7.0, docker/metadata-action@v6.2.0 and docker/build-push-action@v7.4.0 to push the image tagged with the commit SHA (see Docker: CI/CD with GitHub Actions and Containers, Docker and Kubernetes). Three rules differ: wp-config.php reads environment variables from the platform’s secret store (see wp-config.php Reference); only wp-content/uploads is a volume, never all of /var/www/html, or the first image’s code persists in the volume and hides every later image; and wp core update-db runs once after the rollout (see Scaling and High Availability).

Releasing that image to a Kubernetes cluster (rolling updates, the migration job, autoscaling and rollback) is covered in Releases, Upgrades and Autoscaling on Kubernetes.

Zero-downtime releases

Copying files over a live site serves a mix of old and new code while the copy runs, and a request that includes one new and one old file can fatal. Instead, each release is uploaded complete to its own directory and the document root is a symlink switched in one step. /srv/lumenpress/releases/<id>/ holds one complete docroot per release (core, wp-content code, wp-config.php); current is a symlink to one of them and is the web server’s document root; shared/ holds config.php (credentials, keys and salts, WP_HOME, WP_ENVIRONMENT_TYPE) and uploads/; bin/ holds the two scripts below, and releases.log the activation history. Each release’s wp-config.php comes from Git and holds no secret:

<?php
/**
 * wp-config.php shipped in every Lumen Press release.
 * Secrets and per-environment values live outside the release, on the server.
 */

require '/srv/lumenpress/shared/config.php'; // DB_*, keys and salts, WP_HOME, WP_SITEURL, WP_ENVIRONMENT_TYPE.

$table_prefix = 'lp_';

// Code only changes through a deployment.
define( 'DISALLOW_FILE_MODS', true );

if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}

require_once ABSPATH . 'wp-settings.php';

Symlinking wp-config.php from shared/ fails: __DIR__ is the real file’s directory, so ABSPATH would be shared/. The script the deploy workflow calls:

#!/usr/bin/env bash
# Usage: activate-release <release-id> [--maintenance]
# Points /srv/lumenpress/current at releases/<release-id> with one atomic rename.
set -euo pipefail

base="${LUMEN_BASE:-/srv/lumenpress}"
release="$1"
maintenance="${2:-}"
dir="${base}/releases/${release}"
previous="$(readlink -f "${base}/current")"
wp_bare=(wp --skip-plugins --skip-themes)

[ -f "${dir}/wp-settings.php" ] || { echo "Unknown release: ${release}" >&2; exit 1; }

# Shared, writable state is linked in, never copied.
ln -sfn "${base}/shared/uploads" "${dir}/wp-content/uploads"

# Refuse a release that cannot reach the database (WP-CLI loads no plugins here).
wp --path="${dir}" core is-installed

if [ "${maintenance}" = "--maintenance" ]; then
	# .maintenance lives in each release root, so both releases need it.
	"${wp_bare[@]}" --path="${previous}" maintenance-mode activate
	"${wp_bare[@]}" --path="${dir}" maintenance-mode activate
fi

# Atomic swap: create the new link beside the old one, then rename it over it.
ln -sfn "releases/${release}" "${base}/current.next"
mv -T "${base}/current.next" "${base}/current"
echo "${release}" >> "${base}/releases.log"

# Core database upgrade (no plugins loaded), then one full boot so that plugin
# upgrade routines such as Installer::maybe_upgrade() run now, not on a visitor's request.
wp --path="${base}/current" core update-db
wp --path="${base}/current" plugin list --status=active --field=name

if [ "${maintenance}" = "--maintenance" ]; then
	"${wp_bare[@]}" --path="${dir}" maintenance-mode deactivate
	"${wp_bare[@]}" --path="${previous}" maintenance-mode deactivate
fi

# Keep the five most recently uploaded releases (and always the active one).
cd "${base}/releases"
# shellcheck disable=SC2012 # release ids are r<run>-<sha>, never odd file names
ls -1t | tail -n +6 | while read -r old; do
	[ "${old}" = "${release}" ] || rm -rf -- "${old}"
done

echo "Active release: ${release}"

What was checked, and why each line is there:

  • Atomicity. ln -sfn over an existing link deletes it and creates a new one, leaving a moment with no document root; renaming current.next with mv -T (GNU coreutils) is a single atomic rename(2). The script ran on a wordpress:7.1.3-php8.4-apache container (DocumentRoot /srv/lumenpress/current, MariaDB 11.8, the 7.1.4 release from the package steps) while 300 requests hit the home page: all returned 200 and /REVISION switched SHA.

  • Plugin upgrades. WP-CLI defines WP_INSTALLING for core is-installed and core update-db, so neither loads plugins. With Installer::DB_VERSION raised to 2 in the new release, lumen_catalog_db_version stayed 1 after core update-db; the wp plugin list boot raised it to 2. Without that line a visitor’s request runs dbDelta().

  • Old code, new schema. Until every PHP worker serves the new release (and always, in a cluster), old code meets the new schema, so schema changes take two releases: add first, remove later ("expand, then contract").

  • Caches. With nginx and PHP-FPM, pass $realpath_root instead of $document_root in SCRIPT_FILENAME and DOCUMENT_ROOT, so PHP receives real paths and neither its realpath cache nor OPcache serves the previous release. The shared Redis object cache is not flushed; code that changes the shape of cached values uses new keys.

Maintenance mode

During a core update, WordPress writes .maintenance in ABSPATH containing <?php $upgrading = 1793212345; ?>. While that file exists and the timestamp is less than ten minutes old, wp_is_maintenance_mode() is true and wp_maintenance() answers every front-end, admin and REST request with HTTP 503 and Retry-After: 600, using wp-content/maintenance.php as the page when that drop-in exists. After ten minutes the file is ignored, so a crashed update cannot lock a site forever. WP-CLI drives the same file:

wp maintenance-mode activate      # writes .maintenance; errors if already active unless --force
wp maintenance-mode status        # "Maintenance mode is active." / "... is not active."
wp maintenance-mode is-active     # exit code 0 when active, 1 when not (for scripts)
wp maintenance-mode deactivate    # deletes .maintenance

Each release has its own ABSPATH, so maintenance mode set only in the old release would end at the swap; with --maintenance the script writes the file into both. During such a run 200 requests returned 120 times 200 and 80 times 503, and no .maintenance was left behind. A migration longer than ten minutes needs a web-server rule instead.

Database and media sync

Refreshing staging is an export, an import, a URL replacement and a sanitising step, run through the WP-CLI aliases @production and @staging (see WP-CLI):

wp @production db export - --add-drop-table --single-transaction | gzip > lumen-prod.sql.gz
gunzip -c lumen-prod.sql.gz | wp @staging db import -
wp @staging search-replace 'https://lumenpress.example' 'https://staging.lumenpress.example' \
  --all-tables-with-prefix --skip-columns=guid --precise --report-changed-only
wp @staging user list --role=subscriber --field=ID | xargs -r wp @staging user delete --reassign=1
wp @staging cache flush
wp @staging rewrite flush
rsync -az --delete deploy@lumenpress.example:/srv/lumenpress/shared/uploads/ /srv/lumenpress/shared/uploads/
  • wp search-replace unserializes PHP-serialized values (such as lp_catalog_settings), replaces inside them and serializes them again; a SQL REPLACE() corrupts them because serialized strings store their length. --precise forces that PHP path for every column. A dry run on the test site listed six replacements (three in lp_options, two in lp_posts.post_content, one in lp_users.user_url). --skip-columns=guid keeps GUIDs stable. Block attributes, escaped slashes and Multisite are covered in Importing, Exporting and Migrating.

  • wp db export runs mysqldump or mariadb-dump; on the official Apache image, which has no database client, it failed with "mysqldump: No such file or directory".

  • Never import a lower environment’s database into production: it erases every order, comment and user created since the copy. Content goes up only deliberately (WXR import, a migration script, editors working in production).

  • A copy of production is a copy of personal data: anonymise it during the refresh (see Privacy and Personal Data) and keep dumps encrypted and short-lived.

  • Instead of copying uploads, staging and local sites can fetch missing files from production at the web server (try_files $uri @production; with a proxy_pass to https://lumenpress.example).

Rolling back

A rollback repoints current at the previous release; it needs no build and no upload:

#!/usr/bin/env bash
# Usage: rollback-release
# Points /srv/lumenpress/current back at the release that was active before the current one.
set -euo pipefail

base="${LUMEN_BASE:-/srv/lumenpress}"
current="$(basename "$(readlink "${base}/current")")"
previous="$(grep -vx "${current}" "${base}/releases.log" | tail -n 1)"

[ -n "${previous}" ] && [ -d "${base}/releases/${previous}" ] || { echo "No earlier release to roll back to" >&2; exit 1; }

ln -sfn "releases/${previous}" "${base}/current.next"
mv -T "${base}/current.next" "${base}/current"
echo "${previous}" >> "${base}/releases.log"
echo "Rolled back from ${current} to ${previous}"

On the test container it printed Rolled back from r42-9f8e7d6 to r41-1a2b3c4 and /REVISION returned the old SHA. The rollback is itself logged, so running it twice goes forward again. Code rollback does not roll back the database. An "expand" migration leaves the old code working; a destructive one is undone only by restoring the pre-deploy backup (see Backups and Disaster Recovery). Rolling core back across a db_version change is also unsafe: wp-admin compares the stored and expected values for equality and asks for a database "update". After an emergency symlink rollback, revert the commit so main describes production again. Plugins published to the WordPress.org directory are deployed through SVN instead, as shown in Publishing to the Plugin Directory.

Where older books and tutorials are outdated

  • None of the three books covers deployment pipelines, environment types or testing in CI. Their advice to make files writable by the web server (666 or 777) contradicts immutable releases and the 644/755 permissions in File Permissions and Filesystem.

  • Moving a site with SQL replacements, still found in tutorials, corrupts serialized options; use wp search-replace.

  • Uploading changed files over FTP into a live site serves mixed releases and leaves no record of what runs.

  • Tutorials that install the test library with bin/install-wp-tests.sh still work, but wp-env provides it in WP_TESTS_DIR, and wp-env’s separate tests environment is deprecated in 12.0.0.

Common errors and pitfalls

  • Committing secrets: real credentials in wp-config.php, .env files or dumps in the repository.

  • Building on the server: npm and composer install on production add toolchains and network access to the web host; build in CI and ship artifacts.

  • Missing vendor/ or build/: the plugin returns early without its autoloader, and blocks vanish silently without blocks-manifest.php; a test asserts the blocks are registered.

  • A misspelt WP_ENVIRONMENT_TYPE reads as production, so a staging copy mails real customers.

  • Templates customised in the Site Editor keep overriding the deployed theme files; check Appearance > Editor after a theme release.

  • Cron during a deploy: a run of lp_catalog_sync_stock can start on the old release and finish on the new one; keep jobs idempotent.