From 3a2754f5b978e112ff492e78d5d8f2a3261b4a7f Mon Sep 17 00:00:00 2001 From: Felipe Paul Martins Date: Mon, 10 Aug 2026 17:17:48 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat(deploy):=20run=20DB=20migr?= =?UTF-8?q?ations=20during=20provisioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provision.sh never ran `wp spck migrate`, so the migration runner was only ever reachable by hand β€” while wordpress/README.md already claimed migrations ran automatically on deploy. Adds the block before the rewrite flush: count pending migrations, and if any exist export the database, run them, then prune old dumps. Differences from the coraasp.ch implementation this was ported from: - Dumps go to $BACKUP_PATH (default $WORDPRESS_PATH/db-backups), not loose in the webroot, and a deny-all .htaccess is written beside them. A directory we own, so WordPress's own root .htaccess is never touched. nginx needs an equivalent `location` rule β€” documented. - Old dumps are pruned to the newest $BACKUP_KEEP (default 10). - A failed migration prints the backup path and exits 1, so the Actions step goes red instead of leaving the already-swapped new theme live over un-migrated content with a green build. No auto-restore. - A non-numeric `--pending-count` warns instead of being fed to `-gt`. BACKUP_PATH/BACKUP_KEEP are passed through all three deploy workflows so they can be set as GitHub environment variables. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JCMqJx3Ss6Esmc9UbsQ9NX --- .github/workflows/deploy-future.yml | 2 + .github/workflows/deploy-preview.yml | 2 + .github/workflows/deploy-production.yml | 2 + docs/setup/deployment.md | 23 +++++++++ wordpress/README.md | 4 +- wordpress/scripts/provision.sh | 66 +++++++++++++++++++++++++ wordpress/theme/migrations/README.md | 3 +- 7 files changed, 100 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-future.yml b/.github/workflows/deploy-future.yml index 23c492c..e5c9269 100644 --- a/.github/workflows/deploy-future.yml +++ b/.github/workflows/deploy-future.yml @@ -93,6 +93,8 @@ jobs: WORDPRESS_TITLE=\"${{ vars.WORDPRESS_TITLE }}\" \ WORDPRESS_ADMIN_USER=\"${{ vars.WORDPRESS_ADMIN_USER }}\" \ WORDPRESS_ADMIN_EMAIL=\"${{ vars.WORDPRESS_ADMIN_EMAIL }}\" \ + BACKUP_PATH=\"${{ vars.BACKUP_PATH }}\" \ + BACKUP_KEEP=\"${{ vars.BACKUP_KEEP }}\" \ /bin/bash -s " < ./wordpress/scripts/provision.sh deploy_frontend_vercel: diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index c06c30c..9a18203 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -100,6 +100,8 @@ jobs: WORDPRESS_TITLE=\"${{ vars.WORDPRESS_TITLE }}\" \ WORDPRESS_ADMIN_USER=\"${{ vars.WORDPRESS_ADMIN_USER }}\" \ WORDPRESS_ADMIN_EMAIL=\"${{ vars.WORDPRESS_ADMIN_EMAIL }}\" \ + BACKUP_PATH=\"${{ vars.BACKUP_PATH }}\" \ + BACKUP_KEEP=\"${{ vars.BACKUP_KEEP }}\" \ /bin/bash -s " < ./wordpress/scripts/provision.sh deploy_frontend_vercel: diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 5745924..9f4ae71 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -98,6 +98,8 @@ jobs: WORDPRESS_TITLE=\"${{ vars.WORDPRESS_TITLE }}\" \ WORDPRESS_ADMIN_USER=\"${{ vars.WORDPRESS_ADMIN_USER }}\" \ WORDPRESS_ADMIN_EMAIL=\"${{ vars.WORDPRESS_ADMIN_EMAIL }}\" \ + BACKUP_PATH=\"${{ vars.BACKUP_PATH }}\" \ + BACKUP_KEEP=\"${{ vars.BACKUP_KEEP }}\" \ /bin/bash -s " < ./wordpress/scripts/provision.sh deploy_frontend_vercel: diff --git a/docs/setup/deployment.md b/docs/setup/deployment.md index 18275f9..2c15f57 100644 --- a/docs/setup/deployment.md +++ b/docs/setup/deployment.md @@ -55,6 +55,27 @@ This pulls the CSS from your local WordPress instance and writes it to `next/pub - If the step fails with "wp: command not found", ensure WP-CLI is installed on the remote WordPress server - If the CSS file is empty, check that the `wp spck theme-css` command works on the remote server by testing it manually +## πŸ—„ Database migrations during deployment + +Theme changes that require updating existing content in the database ship as migration files in `wordpress/theme/migrations/`. `provision.sh` runs them on every deployment, after the plugin/theme configuration and before the rewrite flush: + +1. Counts pending migrations with `wp spck migrate --pending-count`. If there are none, nothing else happens. +2. Exports the database to `$BACKUP_PATH/db-backup-.sql` (default `$WORDPRESS_PATH/db-backups`). +3. Runs `wp spck migrate`. +4. Prunes the backup directory down to the newest `$BACKUP_KEEP` dumps (default `10`). + +If a migration fails, the provision step **exits non-zero and the workflow goes red**. There is no automatic restore: the theme was already swapped in by the "Deploy Theme" step, so the new theme is live over un-migrated content. The error output prints the exact `wp db import` command to roll the database back. + +> ⚠️ **The default backup directory sits inside the webroot.** `provision.sh` writes a deny-all `.htaccess` next to the dumps, which covers Apache. **On nginx you must add the equivalent rule yourself**, otherwise the dumps are downloadable: +> +> ```nginx +> location ^~ /db-backups/ { +> deny all; +> } +> ``` +> +> Alternatively, set the `BACKUP_PATH` variable to a directory outside the webroot entirely. + ## πŸ” Github Actions variables & secrets The workflows use GitHub [Environments](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) (`staging` and `production`) to scope their configuration. Each environment holds its own set of **variables** (non-sensitive, `vars.*`) and **secrets** (sensitive, `secrets.*`). @@ -90,6 +111,8 @@ These are non-sensitive configuration values. Set them with `gh variable set`. | `NEXT_PATH` | SSH deployment only | Absolute path to the **live Next.js path** on the remote server (example: `/var/www/next/current`). This path becomes a [symlink to the current](#ssh-release-strategy-when-vercel_project_id-is-empty) release. | | `PM2_APP_NAME` | SSH deployment only | PM2 application name used by `.github/actions/next-pm2` to run `pm2 describe`, then either `pm2 restart ` or `pm2 start ecosystem.config.js --only `. | | `KEEP_RELEASES` | SSH deployment only (optional) | Number of most recent releases to keep on server. Default: `5`. | +| `BACKUP_PATH` | Optional | Directory where `provision.sh` writes the DB dump taken before running pending migrations. Default: `$WORDPRESS_PATH/db-backups`. | +| `BACKUP_KEEP` | Optional | Number of most recent DB dumps to keep in `BACKUP_PATH`. Older ones are pruned after each backup. Default: `10`. | | `VERCEL_ORG_ID` | Vercel deployment only | Your Vercel org/user ID. Found at vercel.com β†’ Account settings β†’ General (bottom of page). | | `VERCEL_PROJECT_ID` | Vercel deployment only | Your Vercel project ID. Found at vercel.com β†’ Project β†’ Settings β†’ General (bottom of page). **If set, Vercel deployment is used; if empty, SSH deployment is used.** | diff --git a/wordpress/README.md b/wordpress/README.md index d7442e7..19d6d39 100644 --- a/wordpress/README.md +++ b/wordpress/README.md @@ -34,7 +34,9 @@ Made with ❀️ by [superhuit.ch](https://superhuit.ch) When theme changes require updating existing content in the database (e.g. renaming CSS classes in block markup), migration scripts handle it. -Migrations run automatically during deployment via `provision.sh` (with a DB backup beforehand). They can also be run manually: +Migrations run automatically during deployment via `provision.sh`, which backs the database up to `$BACKUP_PATH` (default `$WORDPRESS_PATH/db-backups`) beforehand and fails the deployment if a migration errors. See [Database migrations during deployment](../docs/setup/deployment.md#-database-migrations-during-deployment) β€” on nginx you need one extra rule to keep the dumps unreachable. + +They can also be run manually: ```sh wp @local spck migrate # run pending migrations diff --git a/wordpress/scripts/provision.sh b/wordpress/scripts/provision.sh index 9cbf74b..5609abb 100644 --- a/wordpress/scripts/provision.sh +++ b/wordpress/scripts/provision.sh @@ -277,6 +277,72 @@ if ! $WPCLI config get "WP_AUTO_UPDATE_CORE" --quiet > /dev/null 2>&1; then echo "βœ”" fi +echo +echo "------------------------------------------------------------------" +echo " Database migrations " +echo "------------------------------------------------------------------" +echo + +# Backups live in a directory we own, never directly in $WORDPRESS_PATH: +# WordPress owns $WORDPRESS_PATH/.htaccess (permalink rules) and we must not +# clobber it. Override with BACKUP_PATH to store dumps outside the webroot. +BACKUP_PATH=${BACKUP_PATH:=$WORDPRESS_PATH/db-backups} +BACKUP_KEEP=${BACKUP_KEEP:=10} + +PENDING=$($WPCLI spck migrate --pending-count 2> /dev/null) +case "$PENDING" in + '' | *[!0-9]*) + echo "⚠ Could not determine the number of pending migrations, skipping." 1>&2 + PENDING=0 + ;; +esac + +if [ "$PENDING" -gt 0 ]; then + mkdir -p "$BACKUP_PATH" + + # Apache: keep the dumps unreachable over HTTP while they sit in the webroot. + # nginx needs the equivalent rule in the server block, see docs/setup/deployment.md: + # location ^~ /db-backups/ { deny all; } + if [ ! -f "$BACKUP_PATH/.htaccess" ]; then + { + echo "# Database dumps written by provision.sh - never serve these over HTTP." + echo "" + echo " Require all denied" + echo "" + echo "" + echo " Order allow,deny" + echo " Deny from all" + echo "" + } > "$BACKUP_PATH/.htaccess" + fi + + BACKUP_FILE="$BACKUP_PATH/db-backup-$(date +%Y%m%d_%H%M%S).sql" + echo $en "- $PENDING pending migration(s), backing up database $ec" + $WPCLI db export "$BACKUP_FILE" --quiet + echo "βœ” ($BACKUP_FILE)" + echo + + $WPCLI spck migrate + MIGRATE_STATUS=$? + + # Prune old dumps, newest $BACKUP_KEEP kept (the one just taken included). + ls -1t "$BACKUP_PATH"/db-backup-*.sql 2> /dev/null | tail -n +$((BACKUP_KEEP + 1)) | while read -r OLD_BACKUP; do + rm -f "$OLD_BACKUP" + done + + if [ "$MIGRATE_STATUS" -ne 0 ]; then + echo 1>&2 + echo "ERROR: database migrations failed (exit $MIGRATE_STATUS)." 1>&2 + echo " The new theme is already live over un-migrated content." 1>&2 + echo " No automatic restore was attempted. To roll the database back:" 1>&2 + echo " wp db import \"$BACKUP_FILE\"" 1>&2 + exit 1 + fi +else + echo "- No pending migrations" +fi + +echo echo $en "- Flushing rewrite rules $ec" $WPCLI rewrite flush --hard --quiet &> /dev/null echo "βœ”" diff --git a/wordpress/theme/migrations/README.md b/wordpress/theme/migrations/README.md index ff33a69..52ddbbb 100644 --- a/wordpress/theme/migrations/README.md +++ b/wordpress/theme/migrations/README.md @@ -61,4 +61,5 @@ Works with WP-CLI aliases: `wp @local spck migrate`, `wp @production spck migrat - **Order matters**: when doing multiple search-replace operations, put the most specific (longest) patterns first to avoid partial matches (e.g. `text-link` before `text`). - **Idempotent by design**: each migration runs only once, tracked by filename in the database. -- **Automatic backup**: `provision.sh` exports the database before running pending migrations. The backup is saved as `db-backup-YYYYMMDD_HHMMSS.sql` in the WordPress root. +- **Automatic backup**: `provision.sh` exports the database before running pending migrations. The backup is saved as `db-backup-YYYYMMDD_HHMMSS.sql` in `$BACKUP_PATH` (default `$WORDPRESS_PATH/db-backups`), and only the newest `$BACKUP_KEEP` dumps are kept (default 10). +- **Failures are fatal**: if a migration fails, the deployment step exits non-zero and the workflow goes red. Nothing is restored automatically β€” the error output prints the `wp db import` command for the backup taken just before the run. See [`docs/setup/deployment.md`](../../../docs/setup/deployment.md) for the full deployment behaviour, including the nginx rule needed to keep the dumps unreachable. From 0b3fa795f6bf08ee6a2d20345e6a6988662efa09 Mon Sep 17 00:00:00 2001 From: Felipe Paul Martins Date: Mon, 10 Aug 2026 17:32:19 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20feat(migrations):=20keep=20an?= =?UTF-8?q?=20audit=20trail=20in=20the=20completed-migrations=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The option stored a flat list of filenames, which answered "did this run?" and nothing else β€” no run date, no duration, no record of a failure. It was also autoloaded on every request for data only WP-CLI ever reads. Entries are now keyed by filename and hold `ran_at`, `duration` and `status`, and `--status` surfaces the two new columns. `get_completed()` shims the old flat format on read, so an existing site keeps its history and does not re-run migrations it has already applied; the next successful run rewrites the option in the new shape. `update_option()` does not reliably flip `autoload` on a row that already exists, so `save_completed()` follows it with `wp_set_option_autoload()`, guarded by `function_exists` (WP 6.6+). Verified against a stubbed `get_option`/`update_option`/`WP_CLI` harness: fresh install, run, re-run no-op, `--status`, `--dry-run` recording nothing, legacy flat-array shim, and a non-array option value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JCMqJx3Ss6Esmc9UbsQ9NX --- wordpress/theme/includes/cli/migrations.php | 81 +++++++++++++++++++-- wordpress/theme/migrations/README.md | 14 ++-- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/wordpress/theme/includes/cli/migrations.php b/wordpress/theme/includes/cli/migrations.php index 1b34cb5..a3d0c5a 100644 --- a/wordpress/theme/includes/cli/migrations.php +++ b/wordpress/theme/includes/cli/migrations.php @@ -82,7 +82,7 @@ public static function run($args, $assoc_args) { $status = \WP_CLI\Utils\get_flag_value($assoc_args, 'status', false); $pending_count = \WP_CLI\Utils\get_flag_value($assoc_args, 'pending-count', false); - $completed = get_option(self::OPTION_NAME, []); + $completed = self::get_completed(); $migrations_dir = SUPERSTACK_PATH . 'migrations/'; if (! is_dir($migrations_dir)) { @@ -105,7 +105,7 @@ public static function run($args, $assoc_args) { $pending = []; foreach ($files as $file) { $name = basename($file); - if (! in_array($name, $completed, true)) { + if (! isset($completed[$name])) { $pending[] = $file; } } @@ -133,6 +133,7 @@ public static function run($args, $assoc_args) { \WP_CLI::log("Running: $name"); + $started = microtime(true); $commands = require $file; if (! is_array($commands)) { @@ -148,8 +149,12 @@ public static function run($args, $assoc_args) { ]); } - $completed[] = $name; - update_option(self::OPTION_NAME, $completed); + $completed[$name] = [ + 'ran_at' => gmdate('Y-m-d H:i:s'), + 'duration' => round(microtime(true) - $started, 3), + 'status' => 'completed', + ]; + self::save_completed($completed); \WP_CLI::success("Completed: $name"); } @@ -160,13 +165,72 @@ public static function run($args, $assoc_args) { } } + /** + * Read the completed migrations, normalised to the current storage format. + * + * Entries are keyed by filename and hold an audit trail: + * `[ 'ran_at' => 'Y-m-d H:i:s', 'duration' => float, 'status' => string ]`. + * Options written by earlier versions hold a flat list of filenames β€” those + * are shimmed to the same shape with an unknown run date and duration. + * + * @access private + * @return array Completed migrations, keyed by filename. + */ + private static function get_completed() { + $stored = get_option(self::OPTION_NAME, []); + + if (! is_array($stored)) { + return []; + } + + $completed = []; + foreach ($stored as $key => $value) { + // Legacy flat format: a plain list of filenames. + if (is_int($key)) { + $completed[$value] = [ + 'ran_at' => null, + 'duration' => null, + 'status' => 'completed', + ]; + continue; + } + + $value = is_array($value) ? $value : []; + $completed[$key] = [ + 'ran_at' => isset($value['ran_at']) ? $value['ran_at'] : null, + 'duration' => isset($value['duration']) ? $value['duration'] : null, + 'status' => isset($value['status']) ? $value['status'] : 'completed', + ]; + } + + return $completed; + } + + /** + * Persist the completed migrations. + * + * @access private + * + * @param array $completed Completed migrations, keyed by filename. + * @return void + */ + private static function save_completed($completed) { + update_option(self::OPTION_NAME, $completed, false); + + // `update_option()` does not reliably flip `autoload` on a row that + // already exists, so set it explicitly (WP 6.6+). + if (function_exists('wp_set_option_autoload')) { + wp_set_option_autoload(self::OPTION_NAME, false); + } + } + /** * Display migration status. * * @access private * * @param array $files All migration file paths. - * @param array $completed List of completed migration filenames. + * @param array $completed Completed migrations, keyed by filename. * @return void */ private static function show_status($files, $completed) { @@ -178,13 +242,16 @@ private static function show_status($files, $completed) { $items = []; foreach ($files as $file) { $name = basename($file); + $entry = isset($completed[$name]) ? $completed[$name] : null; $items[] = [ 'migration' => $name, - 'status' => in_array($name, $completed, true) ? 'completed' : 'pending', + 'status' => $entry ? $entry['status'] : 'pending', + 'ran_at' => $entry && $entry['ran_at'] ? $entry['ran_at'] : '-', + 'duration' => $entry && null !== $entry['duration'] ? $entry['duration'] . 's' : '-', ]; } - \WP_CLI\Utils\format_items('table', $items, ['migration', 'status']); + \WP_CLI\Utils\format_items('table', $items, ['migration', 'status', 'ran_at', 'duration']); } } diff --git a/wordpress/theme/migrations/README.md b/wordpress/theme/migrations/README.md index 52ddbbb..5c88110 100644 --- a/wordpress/theme/migrations/README.md +++ b/wordpress/theme/migrations/README.md @@ -6,7 +6,7 @@ Migration scripts apply one-time database changes required by theme updates (e.g - Migrations are PHP files in this directory, each returning an array of WP-CLI commands (without the `wp` prefix). - A custom WP-CLI command (`wp spck migrate`) runs pending migrations in chronological order. -- Completed migrations are tracked in the `spck_completed_migrations` wp_option β€” each migration runs only once. +- Completed migrations are tracked in the `spck_completed_migrations` wp_option (not autoloaded) β€” each migration runs only once. Each entry keeps an audit trail: when it ran, how long it took, and its status. - During deployment, `provision.sh` checks for pending migrations, creates a DB backup if any exist, then runs them. ## Creating a migration @@ -48,12 +48,12 @@ Migration scripts apply one-time database changes required by theme updates (e.g ## CLI usage -| Command | Description | -| --------------------------------- | --------------------------------------------------- | -| `wp spck migrate` | Run all pending migrations | -| `wp spck migrate --dry-run` | Preview pending migrations without executing | -| `wp spck migrate --status` | Show table of all migrations and their state | -| `wp spck migrate --pending-count` | Output number of pending migrations (for scripting) | +| Command | Description | +| --------------------------------- | ----------------------------------------------------- | +| `wp spck migrate` | Run all pending migrations | +| `wp spck migrate --dry-run` | Preview pending migrations without executing | +| `wp spck migrate --status` | Show all migrations with state, run date and duration | +| `wp spck migrate --pending-count` | Output number of pending migrations (for scripting) | Works with WP-CLI aliases: `wp @local spck migrate`, `wp @production spck migrate`. From 58e3fda12c727af0ed5e0d99b9961d9b14fd8274 Mon Sep 17 00:00:00 2001 From: Felipe Paul Martins Date: Tue, 11 Aug 2026 09:57:41 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20feat(migrations):=20accept=20ca?= =?UTF-8?q?llables,=20add=20--only/--mark-complete,=20baseline=20fresh=20i?= =?UTF-8?q?nstalls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The string-only contract made two things painful or unsafe: any migration needing the table prefix had to hardcode `wp_` in a `db query` (silently a no-op on a differently-prefixed site, and still recorded as completed), and anything with a regex in it turned into quadruple-backslash string concatenation. A migration may now return a callable instead, executed with WordPress fully loaded ($wpdb, WP_Query, WP_CLI::runcommand). The array form is unchanged and still works. Commands are now run with `exit_error => false` and their return code checked, because `exit_error => true` halts the process before a failure can be recorded. A migration that fails β€” non-zero command, or a throwing callable β€” is stored with `status => 'failed'` and counts as pending again, so the next deployment retries it. Pending is therefore `not recorded OR not completed` rather than `not recorded`. Two new flags: - `--only=` runs a single migration whatever its recorded state, for re-running one after a fix. Only the basename is honoured, so it cannot be pointed outside migrations/. - `--mark-complete` records migrations as done without executing them: the public escape hatch, and what baselining uses. provision.sh baselines on a fresh install (the Rails schema:load model): a database created by the current theme has nothing to migrate, so the existing migrations are marked complete instead of run. This needed FIRSTTIME_INSTALL fixed first β€” it was set to false at the top and never set to true anywhere, so baselining would never have fired. It is now set in the two branches that actually run `core install`. That also wakes the pre-existing first-install block (Sample Page β†’ Home, GraphQL registry seed, rewrite structure), which is intended and only happens on genuinely fresh installs. Verified with a stub harness (no phpunit in the repo, out of scope) driving Migrations::run directly against stubbed get_option/update_option/WP_CLI β€” 53 checks over: the commit 2 behaviour as regressions, callables, a failing command and a throwing callable both recorded as failed and retried on the next run, a migration returning neither array nor callable, --only for both re-runs and pending files, --only rejecting unknown and traversing names, --mark-complete for all and for a single file, and its interaction with --dry-run. Plus `php -l` and `sh -n` on the changed files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JCMqJx3Ss6Esmc9UbsQ9NX --- wordpress/scripts/provision.sh | 11 +- wordpress/theme/includes/cli/migrations.php | 188 ++++++++++++++++++-- wordpress/theme/migrations/README.md | 20 ++- 3 files changed, 203 insertions(+), 16 deletions(-) diff --git a/wordpress/scripts/provision.sh b/wordpress/scripts/provision.sh index 5609abb..3fb1952 100644 --- a/wordpress/scripts/provision.sh +++ b/wordpress/scripts/provision.sh @@ -81,6 +81,7 @@ if ! $WPCLI core is-installed --quiet &> /dev/null; then if [ ! -z "${WORDPRESS_ENV}" ] && [ "${WORDPRESS_ENV}" = "dev" ]; then # we are on local dev environment (in docker) echo $en "- Installing WordPress $ec" $WPCLI core install --url="http://localhost" --title="superstack" --admin_user="superstack" --admin_password="superstack" --admin_email="tech+superstack@superhuit.ch" --quiet &> /dev/null + FIRSTTIME_INSTALL=true echo "βœ”" elif [ ! -f "$WORDPRESS_PATH/p.txt" ]; then echo "ERROR: WordPress does not seem to be installed. Add a file 'p.txt' containing the database password if you want this script to automatically install WordPress for you." 1>&2 @@ -102,6 +103,7 @@ if ! $WPCLI core is-installed --quiet &> /dev/null; then $WPCLI config create --dbhost="$WORDPRESS_DB_HOST" --dbname="$WORDPRESS_DB_NAME" --dbuser="$WORDPRESS_DB_USER" --prompt=dbpass < $WORDPRESS_PATH/p.txt --quiet &> /dev/null $WPCLI core install --url="$WORDPRESS_URL" --title="$WORDPRESS_TITLE" --admin_user="$WORDPRESS_ADMIN_USER" --admin_email="$WORDPRESS_ADMIN_EMAIL" --quiet &> /dev/null rm $WORDPRESS_PATH/p.txt + FIRSTTIME_INSTALL=true echo "βœ”" fi fi @@ -297,7 +299,14 @@ case "$PENDING" in ;; esac -if [ "$PENDING" -gt 0 ]; then +if [ "$FIRSTTIME_INSTALL" = true ]; then + # A database created by this version of the theme has nothing to migrate: + # every existing migration would be a no-op at best. Record them as done + # instead of executing them (the `schema:load` model). + echo $en "- Fresh install, baselining $PENDING migration(s) $ec" + $WPCLI spck migrate --mark-complete --quiet &> /dev/null + echo "βœ”" +elif [ "$PENDING" -gt 0 ]; then mkdir -p "$BACKUP_PATH" # Apache: keep the dumps unreachable over HTTP while they sit in the webroot. diff --git a/wordpress/theme/includes/cli/migrations.php b/wordpress/theme/includes/cli/migrations.php index a3d0c5a..14d9f65 100644 --- a/wordpress/theme/includes/cli/migrations.php +++ b/wordpress/theme/includes/cli/migrations.php @@ -14,7 +14,8 @@ * WP-CLI command to run database migrations. * * Migrations are stored as PHP files in the theme's `migrations/` directory. - * Each file must return an array of WP-CLI commands (without the `wp` prefix). + * Each file must return either an array of WP-CLI commands (without the `wp` + * prefix) or a callable, which is executed with the whole of WordPress loaded. * Completed migrations are tracked in the `spck_completed_migrations` option. * * ## EXAMPLES @@ -28,6 +29,12 @@ * # Show migration status * wp spck migrate --status * + * # Run one migration, whether or not it already ran + * wp spck migrate --only=20260101_120000_fix_cards.php + * + * # Record every pending migration as done without executing it + * wp spck migrate --mark-complete + * * @package Superstack * @subpackage Superstack/CLI * @since 1.0.0 @@ -64,6 +71,18 @@ public static function register() { 'description' => 'Output only the number of pending migrations (for scripting).', 'optional' => true, ], + [ + 'type' => 'assoc', + 'name' => 'only', + 'description' => 'Run a single migration by filename, ignoring whether it already ran.', + 'optional' => true, + ], + [ + 'type' => 'flag', + 'name' => 'mark-complete', + 'description' => 'Record migrations as completed without executing them (baselining).', + 'optional' => true, + ], ], ]); } @@ -81,6 +100,8 @@ public static function run($args, $assoc_args) { $dry_run = \WP_CLI\Utils\get_flag_value($assoc_args, 'dry-run', false); $status = \WP_CLI\Utils\get_flag_value($assoc_args, 'status', false); $pending_count = \WP_CLI\Utils\get_flag_value($assoc_args, 'pending-count', false); + $only = \WP_CLI\Utils\get_flag_value($assoc_args, 'only', null); + $mark_complete = \WP_CLI\Utils\get_flag_value($assoc_args, 'mark-complete', false); $completed = self::get_completed(); $migrations_dir = SUPERSTACK_PATH . 'migrations/'; @@ -104,8 +125,7 @@ public static function run($args, $assoc_args) { $pending = []; foreach ($files as $file) { - $name = basename($file); - if (! isset($completed[$name])) { + if (self::is_pending(basename($file), $completed)) { $pending[] = $file; } } @@ -115,11 +135,21 @@ public static function run($args, $assoc_args) { return; } + // `--only` addresses one migration by name, whatever its recorded state. + if ($only) { + $pending = [self::resolve_only($only, $files, $migrations_dir)]; + } + if (empty($pending)) { \WP_CLI::success('No pending migrations.'); return; } + if ($mark_complete) { + self::mark_complete($pending, $completed, $dry_run); + return; + } + \WP_CLI::log(sprintf('%d pending migration(s).', count($pending))); \WP_CLI::log(''); @@ -133,20 +163,27 @@ public static function run($args, $assoc_args) { \WP_CLI::log("Running: $name"); - $started = microtime(true); - $commands = require $file; + $started = microtime(true); + $migration = require $file; - if (! is_array($commands)) { - \WP_CLI::warning("Migration $name did not return an array. Skipping."); + if (! is_array($migration) && ! is_callable($migration)) { + \WP_CLI::warning("Migration $name did not return an array or a callable. Skipping."); continue; } - foreach ($commands as $command) { - \WP_CLI::log(" > wp $command"); - \WP_CLI::runcommand($command, [ - 'return' => false, - 'exit_error' => true, - ]); + try { + self::execute($migration); + } catch (\Throwable $error) { + // Record the failure before bailing: the entry is still counted + // as pending, so the next run retries this migration. + $completed[$name] = [ + 'ran_at' => gmdate('Y-m-d H:i:s'), + 'duration' => round(microtime(true) - $started, 3), + 'status' => 'failed', + ]; + self::save_completed($completed); + + \WP_CLI::error("Failed: $name β€” " . $error->getMessage()); } $completed[$name] = [ @@ -165,6 +202,131 @@ public static function run($args, $assoc_args) { } } + /** + * Execute a migration's payload. + * + * A migration returns either a list of WP-CLI command strings (the original + * contract) or a callable, which runs with WordPress fully loaded β€” use that + * form whenever the migration needs `$wpdb->prefix`, `WP_Query`, or anything + * else that does not survive being squeezed into a shell string. + * + * @access private + * + * @param array|callable $migration Value returned by the migration file. + * @throws \RuntimeException When a WP-CLI command exits non-zero. + * @return void + */ + private static function execute($migration) { + if (! is_array($migration)) { + call_user_func($migration); + return; + } + + foreach ($migration as $command) { + \WP_CLI::log(" > wp $command"); + + // `exit_error` would halt the process before the failure could be + // recorded, so inspect the return code and throw instead. + $result = \WP_CLI::runcommand($command, [ + 'return' => 'all', + 'exit_error' => false, + ]); + + $stdout = isset($result->stdout) ? rtrim($result->stdout) : ''; + if ('' !== $stdout) { + \WP_CLI::log($stdout); + } + + if (0 !== $result->return_code) { + $stderr = isset($result->stderr) ? trim($result->stderr) : ''; + throw new \RuntimeException(sprintf( + '`wp %s` exited with %d%s', + $command, + $result->return_code, + '' !== $stderr ? ': ' . $stderr : '' + )); + } + } + } + + /** + * Resolve the `--only` value to a known migration file path. + * + * Only the basename is honoured, so the flag cannot be pointed at a PHP file + * outside the migrations directory. + * + * @access private + * + * @param string $only Filename passed to `--only`. + * @param array $files All migration file paths. + * @param string $migrations_dir Absolute path to the migrations directory. + * @return string Absolute path to the migration file. + */ + private static function resolve_only($only, $files, $migrations_dir) { + $file = $migrations_dir . basename($only); + + if (! in_array($file, $files, true)) { + \WP_CLI::error(sprintf('Migration "%s" not found in %s', basename($only), $migrations_dir)); + } + + return $file; + } + + /** + * Record migrations as completed without executing them. + * + * Used to baseline a fresh install, where the database is created by the + * current version of the theme and every existing migration is a no-op. + * + * @access private + * + * @param array $files Migration file paths to mark. + * @param array $completed Completed migrations, keyed by filename. + * @param bool $dry_run Whether to preview instead of recording. + * @return void + */ + private static function mark_complete($files, $completed, $dry_run) { + foreach ($files as $file) { + $name = basename($file); + + if ($dry_run) { + \WP_CLI::log("[dry-run] Would mark as completed: $name"); + continue; + } + + // No duration: nothing was executed. + $completed[$name] = [ + 'ran_at' => gmdate('Y-m-d H:i:s'), + 'duration' => null, + 'status' => 'completed', + ]; + \WP_CLI::log("Marked as completed without running: $name"); + } + + if ($dry_run) { + return; + } + + self::save_completed($completed); + \WP_CLI::success(sprintf('%d migration(s) marked as completed.', count($files))); + } + + /** + * Whether a migration still needs to run. + * + * A migration recorded as `failed` counts as pending so the next run retries + * it once the cause has been fixed. + * + * @access private + * + * @param string $name Migration filename. + * @param array $completed Completed migrations, keyed by filename. + * @return bool + */ + private static function is_pending($name, $completed) { + return ! isset($completed[$name]) || 'completed' !== $completed[$name]['status']; + } + /** * Read the completed migrations, normalised to the current storage format. * diff --git a/wordpress/theme/migrations/README.md b/wordpress/theme/migrations/README.md index 5c88110..7168c9e 100644 --- a/wordpress/theme/migrations/README.md +++ b/wordpress/theme/migrations/README.md @@ -4,10 +4,11 @@ Migration scripts apply one-time database changes required by theme updates (e.g ## How it works -- Migrations are PHP files in this directory, each returning an array of WP-CLI commands (without the `wp` prefix). +- Migrations are PHP files in this directory, each returning either an array of WP-CLI commands (without the `wp` prefix) or a callable, which runs with WordPress fully loaded. - A custom WP-CLI command (`wp spck migrate`) runs pending migrations in chronological order. - Completed migrations are tracked in the `spck_completed_migrations` wp_option (not autoloaded) β€” each migration runs only once. Each entry keeps an audit trail: when it ran, how long it took, and its status. - During deployment, `provision.sh` checks for pending migrations, creates a DB backup if any exist, then runs them. +- On a **fresh install** `provision.sh` baselines instead: existing migrations are recorded as completed without being executed, since a database created by the current theme has nothing to migrate. ## Creating a migration @@ -19,7 +20,7 @@ Migration scripts apply one-time database changes required by theme updates (e.g The timestamp prefix ensures migrations run in the correct order. -2. The file must return an array of WP-CLI commands (without `wp` prefix): +2. The file must return an array of WP-CLI commands (without `wp` prefix)… ```php query( + "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, 'old-value', 'new-value')" + ); + }; + ``` + 3. Commit the migration file alongside the theme changes that require it. ## CLI usage @@ -54,6 +67,8 @@ Migration scripts apply one-time database changes required by theme updates (e.g | `wp spck migrate --dry-run` | Preview pending migrations without executing | | `wp spck migrate --status` | Show all migrations with state, run date and duration | | `wp spck migrate --pending-count` | Output number of pending migrations (for scripting) | +| `wp spck migrate --only=` | Run one migration by filename, even if it already ran | +| `wp spck migrate --mark-complete` | Record pending migrations as done without running | Works with WP-CLI aliases: `wp @local spck migrate`, `wp @production spck migrate`. @@ -62,4 +77,5 @@ Works with WP-CLI aliases: `wp @local spck migrate`, `wp @production spck migrat - **Order matters**: when doing multiple search-replace operations, put the most specific (longest) patterns first to avoid partial matches (e.g. `text-link` before `text`). - **Idempotent by design**: each migration runs only once, tracked by filename in the database. - **Automatic backup**: `provision.sh` exports the database before running pending migrations. The backup is saved as `db-backup-YYYYMMDD_HHMMSS.sql` in `$BACKUP_PATH` (default `$WORDPRESS_PATH/db-backups`), and only the newest `$BACKUP_KEEP` dumps are kept (default 10). +- **Failures are retried**: a migration that fails is recorded with `status: failed` and counts as pending again, so the next deployment re-runs it once the cause is fixed. Write migrations so a partial run can be repeated safely. - **Failures are fatal**: if a migration fails, the deployment step exits non-zero and the workflow goes red. Nothing is restored automatically β€” the error output prints the `wp db import` command for the backup taken just before the run. See [`docs/setup/deployment.md`](../../../docs/setup/deployment.md) for the full deployment behaviour, including the nginx rule needed to keep the dumps unreachable. From da175b0bff1cdd3abc3156ff534f4567c2696a79 Mon Sep 17 00:00:00 2001 From: Felipe Paul Martins Date: Tue, 11 Aug 2026 10:05:10 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20feat(migrations):=20scaffold=20?= =?UTF-8?q?migration=20files=20with=20npm=20run=20generate:migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing a migration by hand means getting the timestamp prefix, the ABSPATH guard and the docblock right every time, and the two return forms are easy to forget. `npm run generate:migration "fix section cards"` now writes wordpress/theme/migrations/_fix_section_cards.php from a template carrying all of it, prompting for the description when no argument is passed. The template presents both return forms β€” the array of WP-CLI commands and the callable β€” with the `wp_` prefix trap spelled out beside the callable, so the author picks deliberately rather than defaulting to strings. Follows the conventions of generators/lang-migration.js (docblock with both usages, `paths` object, log()/warn(), readline/promises, main().catch()), but substitutes tokens instead of copying the template verbatim. `generators/**` is outside prettier's scope, so the tab-indented style is matched by hand. Verified by running the generator on "Fix section cards β€” gap!" (slug collapses to fix_section_cards_gap), `php -l`-ing the result, and checking that a description with no alphanumerics exits 1 without writing a file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JCMqJx3Ss6Esmc9UbsQ9NX --- generators/migration.js | 146 +++++++++++++++++++ generators/templates/migration/migration.php | 36 +++++ package.json | 3 +- wordpress/theme/migrations/README.md | 12 +- 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 generators/migration.js create mode 100644 generators/templates/migration/migration.php diff --git a/generators/migration.js b/generators/migration.js new file mode 100644 index 0000000..e166aaf --- /dev/null +++ b/generators/migration.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +/** + * Scaffold a database migration for the WordPress theme. + * + * Interactive: + * npm run generate:migration + * + * Non-interactive: + * npm run generate:migration "fix section cards" + * node generators/migration.js fix section cards + * + * Writes wordpress/theme/migrations/_.php from the + * template in generators/templates/migration/, with the description filled in. + * The timestamp prefix is what orders migrations, so the file is named at + * creation time and never renamed afterwards. + * + * See wordpress/theme/migrations/README.md for the runner contract and the + * `wp spck migrate` flags. + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline/promises'); + +const rootDir = path.resolve(__dirname, '..'); +const templatesDir = path.join(__dirname, 'templates', 'migration'); + +const paths = { + template: path.join(templatesDir, 'migration.php'), + migrationsDir: path.join(rootDir, 'wordpress/theme/migrations'), +}; + +const log = (message) => console.log(`βœ” ${message}`); +const warn = (message) => console.warn(`⚠ ${message}`); + +const slugify = (description) => + description + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + +function timestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '_', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(''); +} + +async function prompt() { + const description = process.argv.slice(2).join(' ').trim(); + + if (description) return description; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + let answer; + while (!answer) { + answer = ( + await rl.question( + 'Short description of the migration (e.g. "fix section cards"): ' + ) + ).trim(); + + if (answer && !slugify(answer)) { + console.log( + 'Please use a description with at least one letter or digit.' + ); + answer = ''; + } + } + + return answer; + } finally { + rl.close(); + } +} + +function writeMigration(description) { + const slug = slugify(description); + if (!slug) { + console.error( + `Cannot derive a filename from "${description}" β€” use at least one letter or digit.` + ); + process.exit(1); + } + + if (!fs.existsSync(paths.migrationsDir)) { + warn( + `${path.relative(rootDir, paths.migrationsDir)} not found, creating it` + ); + fs.mkdirSync(paths.migrationsDir, { recursive: true }); + } + + const destination = path.join( + paths.migrationsDir, + `${timestamp()}_${slug}.php` + ); + + if (fs.existsSync(destination)) { + console.error( + `${path.relative(rootDir, destination)} already exists, aborting.` + ); + process.exit(1); + } + + const contents = fs + .readFileSync(paths.template, 'utf8') + .replace(/\{\{description\}\}/g, description); + + fs.writeFileSync(destination, contents); + log(`Wrote ${path.relative(rootDir, destination)}`); + + return destination; +} + +async function main() { + const description = await prompt(); + const destination = writeMigration(description); + + console.log(` +Next steps: + 1. Edit ${path.relative(rootDir, destination)}: keep either the array of + WP-CLI commands or the callable, and complete the docblock. + 2. Preview it with "wp spck migrate --dry-run", then run it against a copy of + the production database with "wp spck migrate --only=${path.basename(destination)}". + 3. Commit the migration alongside the theme changes that require it. +`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/generators/templates/migration/migration.php b/generators/templates/migration/migration.php new file mode 100644 index 0000000..917215e --- /dev/null +++ b/generators/templates/migration/migration.php @@ -0,0 +1,36 @@ + + * + * @package Superstack + * @since 1.0.0 + */ + +if (! defined('ABSPATH')) { + exit; +} + +// Keep one of the two forms below and delete the other. + +// Either an array of WP-CLI commands, without the `wp` prefix… +return [ + 'search-replace "old-value" "new-value" --report-changed-only', +]; + +// …or a callable, which runs with WordPress fully loaded. Prefer this form as +// soon as the migration needs the table prefix β€” hardcoding `wp_` in a +// `db query` silently does nothing on a site with a different prefix β€” or as +// soon as the command string needs escaping gymnastics. +// +// return function () { +// global $wpdb; +// +// $wpdb->query( +// "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, 'old-value', 'new-value')" +// ); +// }; diff --git a/package.json b/package.json index 415502a..b0a537d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "prepare": "husky", "format": "prettier --write \"next/**/*.{js,jsx,ts,tsx,json,css,md,yml,yaml}\" \"wordpress/theme/**/*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml}\"", "format:check": "prettier --check \"next/**/*.{js,jsx,ts,tsx,json,css,md,yml,yaml}\" \"wordpress/theme/**/*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml}\"", - "generate:language-migration": "node generators/lang-migration.js" + "generate:language-migration": "node generators/lang-migration.js", + "generate:migration": "node generators/migration.js" }, "lint-staged": { "next/**/*.{js,jsx,ts,tsx,json,css,md,yml,yaml}": [ diff --git a/wordpress/theme/migrations/README.md b/wordpress/theme/migrations/README.md index 7168c9e..4db5f66 100644 --- a/wordpress/theme/migrations/README.md +++ b/wordpress/theme/migrations/README.md @@ -12,7 +12,15 @@ Migration scripts apply one-time database changes required by theme updates (e.g ## Creating a migration -1. Create a new PHP file with the naming convention: +1. Scaffold the file from the repository root: + + ```sh + npm run generate:migration "fix section cards" + ``` + + This writes `wordpress/theme/migrations/_fix_section_cards.php` from a template holding the docblock, the `ABSPATH` guard and both return forms. Run it without an argument to be prompted for the description. + + The naming convention, if you write the file by hand instead: ``` YYYYMMDD_HHMMSS_short_description.php @@ -20,7 +28,7 @@ Migration scripts apply one-time database changes required by theme updates (e.g The timestamp prefix ensures migrations run in the correct order. -2. The file must return an array of WP-CLI commands (without `wp` prefix)… +2. Keep one of the two return forms. Either an array of WP-CLI commands (without `wp` prefix)… ```php Date: Tue, 11 Aug 2026 10:09:56 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=93=9D=20docs(migrations):=20add=20th?= =?UTF-8?q?e=20add-db-migration=20authoring=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The judgement call agents get wrong is not how to write a migration but whether to write one at all β€” a deprecation in the block's `deprecated` array or a fallback in the render callback is nearly always better than a script that runs irreversibly against production content. The skill puts that decision first, as three conditions that must all hold, and stops if any fails. The rest is the authoring material commits 1–3 left implicit: scaffolding with `npm run generate:migration`, callable-by-default with the `wp_` prefix trap and escaping as the two triggers to switch away from command strings, and re-runnability β€” a failed migration is recorded as `failed`, counts as pending again, and is re-run on the next deployment over a database where part of the work may already be done. Deploy behaviour, backup location, the audit trail and the flag list are already documented in docs/setup/deployment.md, wordpress/README.md and wordpress/theme/migrations/README.md; the skill links to them rather than restating them. The migrations README gains a pointer back to the skill. Also fixes the link to the migrations README in wordpress/README.md, which was repo-relative in a file one level down. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JCMqJx3Ss6Esmc9UbsQ9NX --- .claude/skills/add-db-migration/SKILL.md | 96 ++++++++++++++++++++++++ wordpress/README.md | 2 +- wordpress/theme/migrations/README.md | 2 + 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/add-db-migration/SKILL.md diff --git a/.claude/skills/add-db-migration/SKILL.md b/.claude/skills/add-db-migration/SKILL.md new file mode 100644 index 0000000..27d6d4b --- /dev/null +++ b/.claude/skills/add-db-migration/SKILL.md @@ -0,0 +1,96 @@ +--- +description: Author a database migration for the WordPress theme in this headless WordPress + Next.js stack. Use when the user asks to add a migration, says existing content needs updating in the database, or makes a theme change (renamed block, renamed CSS class, changed attribute) that leaves already-saved content on the old shape. +compatibility: Superstack β€” headless WordPress + Next.js monorepo +--- + +A migration rewrites content that is **already saved in the database** so it matches a theme change. It is authoring work only β€” running migrations on servers is `provision.sh`'s job, documented in [`docs/setup/deployment.md`](../../../docs/setup/deployment.md). The runner contract and the full flag list live in [`wordpress/theme/migrations/README.md`](../../../wordpress/theme/migrations/README.md); read it before writing the file. + +## Required Pre-Checks + +1. **Decide whether a migration is warranted at all.** This is the step that gets skipped. A migration is warranted only when all three hold: + - Content already saved in the database carries the old shape (a class name, an attribute value, an option, a term). Grep `wp_posts.post_content` on a real database rather than assuming. + - The theme change does not handle the old shape at render time. A deprecation in the block's `deprecated` array, a fallback in the PHP render callback, or a default in the Next.js component makes the migration unnecessary β€” prefer those, they cannot fail on a production database. + - The old shape would visibly break or silently disappear for editors or visitors. + + If any of the three fails, say so and stop. Writing an unnecessary migration is the more expensive mistake: it runs irreversibly against production content. +2. Confirm which theme change the migration accompanies β€” the migration and that change ship in the same commit, so the database never lags the code that reads it. +3. Confirm the search pattern is specific enough. When several replacements are involved, order the most specific (longest) first so `text-link` is not half-eaten by `text`. + +## Step 1 β€” Scaffold the file + +```sh +npm run generate:migration "fix section cards" +``` + +Writes `wordpress/theme/migrations/_fix_section_cards.php` with the docblock, the `ABSPATH` guard and both return forms. Never rename it afterwards β€” the timestamp prefix is the run order, and the filename is the key the completed-migrations option is stored under. + +Fill in the docblock: what the migration does, why, and the commit hash of the accompanying theme change. + +## Step 2 β€” Choose the return form + +Keep one of the two forms the template offers and delete the other. + +**A callable** is the default choice. It runs with WordPress fully loaded, so `$wpdb`, `WP_Query` and `WP_CLI::runcommand` are all available: + +```php +return function () { + global $wpdb; + + $wpdb->query( + "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, 'old-value', 'new-value')" + ); +}; +``` + +**An array of WP-CLI command strings** is worth keeping only when the whole migration is one or two clean `search-replace` calls with no quoting to fight: + +```php +return [ + 'search-replace "old-value" "new-value" --report-changed-only', +]; +``` + +Switch to the callable as soon as either of these appears: + +- **The table prefix.** Hardcoding `wp_` in a `db query` silently matches nothing on a site with a different prefix β€” and the migration is still recorded as completed, so the damage is invisible and permanent. Use `$wpdb->posts`, `$wpdb->postmeta`, `$wpdb->prefix` instead. +- **Escaping.** A regex or JSON fragment inside a shell string inside a PHP string means backslash arithmetic. Write PHP. + +## Step 3 β€” Make it re-runnable + +A migration that throws is recorded with `status: failed`, which counts as **pending again** β€” the next deployment runs it a second time, on a database where part of the work may already be done. Write every migration so that second run is harmless: + +- Match on the old shape only, so a row already migrated no longer matches. +- Prefer `REPLACE(...)`-style rewrites and `WHERE ... LIKE '%old-value%'` guards over blanket updates. +- Where a value is computed, check for the target state before writing it. +- Avoid appending: a second run appends twice. + +Blocks stored in `post_content` are serialised block comments β€” a targeted string replacement on the exact class or attribute is safer than re-serialising the block. + +## Step 4 β€” Test against a copy of the production database + +Never let the first real run happen on production. On a local site restored from a production dump: + +```sh +wp spck migrate --dry-run # confirms the runner sees the file +wp spck migrate --only=.php # runs just this one, whatever its recorded state +wp spck migrate --only=.php # run it a second time β€” Step 3's promise +wp spck migrate --status # status "completed", with ran_at and duration +``` + +`--only` re-runs a single migration by filename regardless of recorded state, which is what makes the idempotency check above possible. Between runs, verify the content itself: query the affected rows, and load an affected page in Next.js. + +## Verification Checklist + +1. `php -l wordpress/theme/migrations/.php` +2. Migration ran clean against a production-database copy, and running it twice changed nothing the second time. +3. `wp spck migrate --status` shows it `completed`. +4. Affected content renders correctly in WordPress admin and in Next.js. +5. Docblock names the accompanying theme change. +6. No other migration file was modified β€” migrations that already ran are immutable history. + +## Output Format + +1. Why a migration is warranted (the three Pre-Check conditions), or why it is not. +2. The migration file created and the theme change it accompanies. +3. Return form chosen and why. +4. Verification results, including the second-run check. diff --git a/wordpress/README.md b/wordpress/README.md index 19d6d39..29c1048 100644 --- a/wordpress/README.md +++ b/wordpress/README.md @@ -44,7 +44,7 @@ wp @local spck migrate --dry-run # preview without executing wp @local spck migrate --status # show all migrations and their state ``` -See [`wordpress/theme/migrations/README.md`](wordpress/theme/migrations/README.md) for details on creating and managing migrations. +See [`wordpress/theme/migrations/README.md`](theme/migrations/README.md) for details on creating and managing migrations. ## Theme CSS Generation diff --git a/wordpress/theme/migrations/README.md b/wordpress/theme/migrations/README.md index 4db5f66..4262c2b 100644 --- a/wordpress/theme/migrations/README.md +++ b/wordpress/theme/migrations/README.md @@ -12,6 +12,8 @@ Migration scripts apply one-time database changes required by theme updates (e.g ## Creating a migration +Deciding whether a change actually needs a migration, choosing between the two return forms, and making the file safe to re-run are covered by the `add-db-migration` skill ([`.claude/skills/add-db-migration/SKILL.md`](../../../.claude/skills/add-db-migration/SKILL.md)). + 1. Scaffold the file from the repository root: ```sh From 6df1d44dbdae442696d8cfe450419d2c84622f32 Mon Sep 17 00:00:00 2001 From: Felipe Paul Martins Date: Tue, 11 Aug 2026 11:03:53 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=94=A8=20refactor(migrations):=20appl?= =?UTF-8?q?y=20code-review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards axis: - provision.sh: BACKUP_PATH and BACKUP_KEEP sat ~250 lines below the file's own "/!\ STOP to edit here /!\" marker, while the header says "Edit only the variables below". Moved them into the top variable block beside IS_MULTILANG, rationale comment included. - provision.sh: an unparseable --pending-count printed the warning and then fell through to "No pending migrations", two contradictory lines for one condition. The unknown count is now its own branch and says only the warning. - migrations.php: the audit-trail record literal was rebuilt at three write sites differing only in status and duration. Collapsed into entry(). The legacy shim keeps its own literal β€” it records unknown provenance, not a run. - migrations.php: the status strings were typed at four sites and compared literally at two, where a typo would leave a migration permanently pending. Now STATUS_COMPLETED / STATUS_FAILED constants, matching the file's existing OPTION_NAME convention. - generators: the two generators shared rootDir, log()/warn(), the readline create/try/finally/close lifecycle and the main().catch() tail verbatim. Extracted to generators/lib.js and used by both. Spec axis: - The scaffold template left the array form active, so a file committed unedited was a valid pending migration that would run a site-wide search-replace on the next deploy. Both forms are now commented out: an untouched file returns nothing and the runner skips it with a warning. The migrations README and the skill say so. Verified: `sh -n provision.sh`, `php -l migrations.php`, both generators run non-interactively and interactively with php -l clean output, withPrompt closes its interface, prettier clean. Declined from the review, per the user: the coraasp divergence note, the revert-conflict finding (the series lands squashed), and the Actions-variable scope-creep flag (per-environment config is wanted). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JCMqJx3Ss6Esmc9UbsQ9NX --- .claude/skills/add-db-migration/SKILL.md | 4 +- generators/lang-migration.js | 22 ++------- generators/lib.js | 44 ++++++++++++++++++ generators/migration.js | 22 ++------- generators/templates/migration/migration.php | 11 +++-- wordpress/scripts/provision.sh | 20 +++++---- wordpress/theme/includes/cli/migrations.php | 47 ++++++++++++-------- wordpress/theme/migrations/README.md | 2 +- 8 files changed, 102 insertions(+), 70 deletions(-) create mode 100644 generators/lib.js diff --git a/.claude/skills/add-db-migration/SKILL.md b/.claude/skills/add-db-migration/SKILL.md index 27d6d4b..7e82849 100644 --- a/.claude/skills/add-db-migration/SKILL.md +++ b/.claude/skills/add-db-migration/SKILL.md @@ -22,13 +22,13 @@ A migration rewrites content that is **already saved in the database** so it mat npm run generate:migration "fix section cards" ``` -Writes `wordpress/theme/migrations/_fix_section_cards.php` with the docblock, the `ABSPATH` guard and both return forms. Never rename it afterwards β€” the timestamp prefix is the run order, and the filename is the key the completed-migrations option is stored under. +Writes `wordpress/theme/migrations/_fix_section_cards.php` with the docblock, the `ABSPATH` guard and both return forms commented out. Never rename it afterwards β€” the timestamp prefix is the run order, and the filename is the key the completed-migrations option is stored under. Fill in the docblock: what the migration does, why, and the commit hash of the accompanying theme change. ## Step 2 β€” Choose the return form -Keep one of the two forms the template offers and delete the other. +Uncomment one of the two forms the template offers and delete the other. A file that returns neither is skipped by the runner with a warning and stays pending forever, so this step is not optional. **A callable** is the default choice. It runs with WordPress fully loaded, so `$wpdb`, `WP_Query` and `WP_CLI::runcommand` are all available: diff --git a/generators/lang-migration.js b/generators/lang-migration.js index d685092..bb3a2db 100644 --- a/generators/lang-migration.js +++ b/generators/lang-migration.js @@ -28,9 +28,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const readline = require('readline/promises'); +const { rootDir, log, warn, withPrompt, run } = require('./lib'); -const rootDir = path.resolve(__dirname, '..'); const templatesDir = path.join(__dirname, 'templates', 'lang-migration'); const paths = { @@ -48,9 +47,6 @@ const paths = { provisionSh: path.join(rootDir, 'wordpress/scripts/provision.sh'), }; -const log = (message) => console.log(`βœ” ${message}`); -const warn = (message) => console.warn(`⚠ ${message}`); - const isValidLocale = (value) => /^[a-z]{2}(-[a-z]{2})?$/.test(value); async function prompt() { @@ -83,12 +79,7 @@ async function prompt() { }; } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { + return withPrompt(async (rl) => { let migrationType; while (!migrationType) { const answer = ( @@ -140,9 +131,7 @@ async function prompt() { } return { migrationType, defaultLocale, additionalLocales }; - } finally { - rl.close(); - } + }); } function readConfigs() { @@ -357,7 +346,4 @@ async function main() { } } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +run(main); diff --git a/generators/lib.js b/generators/lib.js new file mode 100644 index 0000000..2d04a92 --- /dev/null +++ b/generators/lib.js @@ -0,0 +1,44 @@ +/** + * Shared plumbing for the generators in this directory. + * + * Nothing here is generator-specific β€” the repository root every generator + * resolves paths against, the βœ”/⚠ reporting both use, the readline lifecycle + * their interactive prompts share, and the top-level error handling. + */ + +const path = require('path'); +const readline = require('readline/promises'); + +const rootDir = path.resolve(__dirname, '..'); + +const log = (message) => console.log(`βœ” ${message}`); +const warn = (message) => console.warn(`⚠ ${message}`); + +/** + * Run `callback` with a readline interface, closing it whatever happens. + */ +async function withPrompt(callback) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + return await callback(rl); + } finally { + rl.close(); + } +} + +/** + * Entry point wrapper: report the error and exit non-zero rather than + * printing an unhandled rejection. + */ +function run(main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} + +module.exports = { rootDir, log, warn, withPrompt, run }; diff --git a/generators/migration.js b/generators/migration.js index e166aaf..2a4e45d 100644 --- a/generators/migration.js +++ b/generators/migration.js @@ -21,9 +21,8 @@ const fs = require('fs'); const path = require('path'); -const readline = require('readline/promises'); +const { rootDir, log, warn, withPrompt, run } = require('./lib'); -const rootDir = path.resolve(__dirname, '..'); const templatesDir = path.join(__dirname, 'templates', 'migration'); const paths = { @@ -31,9 +30,6 @@ const paths = { migrationsDir: path.join(rootDir, 'wordpress/theme/migrations'), }; -const log = (message) => console.log(`βœ” ${message}`); -const warn = (message) => console.warn(`⚠ ${message}`); - const slugify = (description) => description .toLowerCase() @@ -60,12 +56,7 @@ async function prompt() { if (description) return description; - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { + return withPrompt(async (rl) => { let answer; while (!answer) { answer = ( @@ -83,9 +74,7 @@ async function prompt() { } return answer; - } finally { - rl.close(); - } + }); } function writeMigration(description) { @@ -140,7 +129,4 @@ Next steps: `); } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +run(main); diff --git a/generators/templates/migration/migration.php b/generators/templates/migration/migration.php index 917215e..78f53b2 100644 --- a/generators/templates/migration/migration.php +++ b/generators/templates/migration/migration.php @@ -15,12 +15,15 @@ exit; } -// Keep one of the two forms below and delete the other. +// Uncomment one of the two forms below and delete the other. Until one is +// uncommented this file returns nothing, so the runner skips it rather than +// running the placeholder against a live database. // Either an array of WP-CLI commands, without the `wp` prefix… -return [ - 'search-replace "old-value" "new-value" --report-changed-only', -]; +// +// return [ +// 'search-replace "old-value" "new-value" --report-changed-only', +// ]; // …or a callable, which runs with WordPress fully loaded. Prefer this form as // soon as the migration needs the table prefix β€” hardcoding `wp_` in a diff --git a/wordpress/scripts/provision.sh b/wordpress/scripts/provision.sh index 3fb1952..4ac6fc9 100644 --- a/wordpress/scripts/provision.sh +++ b/wordpress/scripts/provision.sh @@ -32,6 +32,13 @@ IS_MULTILANG=${IS_MULTILANG:=false} HTTP_HOST=${WORDPRESS_URL} +# Database dumps taken before running pending migrations. +# Backups live in a directory we own, never directly in $WORDPRESS_PATH: +# WordPress owns $WORDPRESS_PATH/.htaccess (permalink rules) and we must not +# clobber it. Override with BACKUP_PATH to store dumps outside the webroot. +BACKUP_PATH=${BACKUP_PATH:=$WORDPRESS_PATH/db-backups} +BACKUP_KEEP=${BACKUP_KEEP:=10} + # #=========================================== # # /!\ STOP to edit here /!\ # #=========================================== @@ -285,17 +292,10 @@ echo " Database migrations " echo "------------------------------------------------------------------" echo -# Backups live in a directory we own, never directly in $WORDPRESS_PATH: -# WordPress owns $WORDPRESS_PATH/.htaccess (permalink rules) and we must not -# clobber it. Override with BACKUP_PATH to store dumps outside the webroot. -BACKUP_PATH=${BACKUP_PATH:=$WORDPRESS_PATH/db-backups} -BACKUP_KEEP=${BACKUP_KEEP:=10} - PENDING=$($WPCLI spck migrate --pending-count 2> /dev/null) case "$PENDING" in '' | *[!0-9]*) - echo "⚠ Could not determine the number of pending migrations, skipping." 1>&2 - PENDING=0 + PENDING=unknown ;; esac @@ -303,9 +303,11 @@ if [ "$FIRSTTIME_INSTALL" = true ]; then # A database created by this version of the theme has nothing to migrate: # every existing migration would be a no-op at best. Record them as done # instead of executing them (the `schema:load` model). - echo $en "- Fresh install, baselining $PENDING migration(s) $ec" + echo $en "- Fresh install, baselining existing migrations $ec" $WPCLI spck migrate --mark-complete --quiet &> /dev/null echo "βœ”" +elif [ "$PENDING" = unknown ]; then + echo "⚠ Could not determine the number of pending migrations, skipping." 1>&2 elif [ "$PENDING" -gt 0 ]; then mkdir -p "$BACKUP_PATH" diff --git a/wordpress/theme/includes/cli/migrations.php b/wordpress/theme/includes/cli/migrations.php index 14d9f65..2161a46 100644 --- a/wordpress/theme/includes/cli/migrations.php +++ b/wordpress/theme/includes/cli/migrations.php @@ -43,6 +43,9 @@ class Migrations { const OPTION_NAME = 'spck_completed_migrations'; + const STATUS_COMPLETED = 'completed'; + const STATUS_FAILED = 'failed'; + /** * Register the WP-CLI command. * @@ -176,21 +179,13 @@ public static function run($args, $assoc_args) { } catch (\Throwable $error) { // Record the failure before bailing: the entry is still counted // as pending, so the next run retries this migration. - $completed[$name] = [ - 'ran_at' => gmdate('Y-m-d H:i:s'), - 'duration' => round(microtime(true) - $started, 3), - 'status' => 'failed', - ]; + $completed[$name] = self::entry(self::STATUS_FAILED, microtime(true) - $started); self::save_completed($completed); \WP_CLI::error("Failed: $name β€” " . $error->getMessage()); } - $completed[$name] = [ - 'ran_at' => gmdate('Y-m-d H:i:s'), - 'duration' => round(microtime(true) - $started, 3), - 'status' => 'completed', - ]; + $completed[$name] = self::entry(self::STATUS_COMPLETED, microtime(true) - $started); self::save_completed($completed); \WP_CLI::success("Completed: $name"); @@ -295,11 +290,7 @@ private static function mark_complete($files, $completed, $dry_run) { } // No duration: nothing was executed. - $completed[$name] = [ - 'ran_at' => gmdate('Y-m-d H:i:s'), - 'duration' => null, - 'status' => 'completed', - ]; + $completed[$name] = self::entry(self::STATUS_COMPLETED); \WP_CLI::log("Marked as completed without running: $name"); } @@ -311,6 +302,24 @@ private static function mark_complete($files, $completed, $dry_run) { \WP_CLI::success(sprintf('%d migration(s) marked as completed.', count($files))); } + /** + * Build an audit-trail entry for the completed-migrations option. + * + * @access private + * + * @param string $status One of the STATUS_* constants. + * @param float|null $elapsed Seconds the migration took, or null when + * nothing was executed. + * @return array + */ + private static function entry($status, $elapsed = null) { + return [ + 'ran_at' => gmdate('Y-m-d H:i:s'), + 'duration' => null === $elapsed ? null : round($elapsed, 3), + 'status' => $status, + ]; + } + /** * Whether a migration still needs to run. * @@ -324,7 +333,7 @@ private static function mark_complete($files, $completed, $dry_run) { * @return bool */ private static function is_pending($name, $completed) { - return ! isset($completed[$name]) || 'completed' !== $completed[$name]['status']; + return ! isset($completed[$name]) || self::STATUS_COMPLETED !== $completed[$name]['status']; } /** @@ -349,10 +358,12 @@ private static function get_completed() { foreach ($stored as $key => $value) { // Legacy flat format: a plain list of filenames. if (is_int($key)) { + // Provenance is unknown, so this is not an entry(): the run date + // was never recorded by the format being shimmed. $completed[$value] = [ 'ran_at' => null, 'duration' => null, - 'status' => 'completed', + 'status' => self::STATUS_COMPLETED, ]; continue; } @@ -361,7 +372,7 @@ private static function get_completed() { $completed[$key] = [ 'ran_at' => isset($value['ran_at']) ? $value['ran_at'] : null, 'duration' => isset($value['duration']) ? $value['duration'] : null, - 'status' => isset($value['status']) ? $value['status'] : 'completed', + 'status' => isset($value['status']) ? $value['status'] : self::STATUS_COMPLETED, ]; } diff --git a/wordpress/theme/migrations/README.md b/wordpress/theme/migrations/README.md index 4262c2b..18a2730 100644 --- a/wordpress/theme/migrations/README.md +++ b/wordpress/theme/migrations/README.md @@ -20,7 +20,7 @@ Deciding whether a change actually needs a migration, choosing between the two r npm run generate:migration "fix section cards" ``` - This writes `wordpress/theme/migrations/_fix_section_cards.php` from a template holding the docblock, the `ABSPATH` guard and both return forms. Run it without an argument to be prompted for the description. + This writes `wordpress/theme/migrations/_fix_section_cards.php` from a template holding the docblock, the `ABSPATH` guard and both return forms, commented out β€” uncomment the one you want. A scaffolded file left untouched returns nothing and is skipped by the runner rather than executing a placeholder. Run the command without an argument to be prompted for the description. The naming convention, if you write the file by hand instead: