diff --git a/.claude/skills/add-db-migration/SKILL.md b/.claude/skills/add-db-migration/SKILL.md new file mode 100644 index 00000000..7e828496 --- /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 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 + +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: + +```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/.github/workflows/deploy-future.yml b/.github/workflows/deploy-future.yml index 23c492c9..e5c92691 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 c06c30c3..9a182037 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 57459241..9f4ae719 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 18275f90..2c15f57c 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/generators/lang-migration.js b/generators/lang-migration.js index d6850924..bb3a2db7 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 00000000..2d04a92f --- /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 new file mode 100644 index 00000000..2a4e45d7 --- /dev/null +++ b/generators/migration.js @@ -0,0 +1,132 @@ +#!/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 { rootDir, log, warn, withPrompt, run } = require('./lib'); + +const templatesDir = path.join(__dirname, 'templates', 'migration'); + +const paths = { + template: path.join(templatesDir, 'migration.php'), + migrationsDir: path.join(rootDir, 'wordpress/theme/migrations'), +}; + +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; + + return withPrompt(async (rl) => { + 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; + }); +} + +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. +`); +} + +run(main); diff --git a/generators/templates/migration/migration.php b/generators/templates/migration/migration.php new file mode 100644 index 00000000..78f53b28 --- /dev/null +++ b/generators/templates/migration/migration.php @@ -0,0 +1,39 @@ + + * + * @package Superstack + * @since 1.0.0 + */ + +if (! defined('ABSPATH')) { + exit; +} + +// 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', +// ]; + +// …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 415502a0..b0a537d4 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/README.md b/wordpress/README.md index d7442e7c..29c10484 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 @@ -42,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/scripts/provision.sh b/wordpress/scripts/provision.sh index 9cbf74bf..4ac6fc91 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 /!\ # #=========================================== @@ -81,6 +88,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 +110,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 @@ -277,6 +286,74 @@ if ! $WPCLI config get "WP_AUTO_UPDATE_CORE" --quiet > /dev/null 2>&1; then echo "✔" fi +echo +echo "------------------------------------------------------------------" +echo " Database migrations " +echo "------------------------------------------------------------------" +echo + +PENDING=$($WPCLI spck migrate --pending-count 2> /dev/null) +case "$PENDING" in + '' | *[!0-9]*) + PENDING=unknown + ;; +esac + +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 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" + + # 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/includes/cli/migrations.php b/wordpress/theme/includes/cli/migrations.php index 1b34cb59..2161a462 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 @@ -36,6 +43,9 @@ class Migrations { const OPTION_NAME = 'spck_completed_migrations'; + const STATUS_COMPLETED = 'completed'; + const STATUS_FAILED = 'failed'; + /** * Register the WP-CLI command. * @@ -64,6 +74,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,8 +103,10 @@ 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 = get_option(self::OPTION_NAME, []); + $completed = self::get_completed(); $migrations_dir = SUPERSTACK_PATH . 'migrations/'; if (! is_dir($migrations_dir)) { @@ -104,8 +128,7 @@ public static function run($args, $assoc_args) { $pending = []; foreach ($files as $file) { - $name = basename($file); - if (! in_array($name, $completed, true)) { + if (self::is_pending(basename($file), $completed)) { $pending[] = $file; } } @@ -115,11 +138,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,23 +166,27 @@ public static function run($args, $assoc_args) { \WP_CLI::log("Running: $name"); - $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] = self::entry(self::STATUS_FAILED, microtime(true) - $started); + self::save_completed($completed); + + \WP_CLI::error("Failed: $name — " . $error->getMessage()); } - $completed[] = $name; - update_option(self::OPTION_NAME, $completed); + $completed[$name] = self::entry(self::STATUS_COMPLETED, microtime(true) - $started); + self::save_completed($completed); \WP_CLI::success("Completed: $name"); } @@ -160,13 +197,213 @@ 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] = self::entry(self::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))); + } + + /** + * 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. + * + * 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]) || self::STATUS_COMPLETED !== $completed[$name]['status']; + } + + /** + * 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)) { + // 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' => self::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'] : self::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 +415,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 ff33a691..18a27302 100644 --- a/wordpress/theme/migrations/README.md +++ b/wordpress/theme/migrations/README.md @@ -4,14 +4,25 @@ 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 — 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. +- 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 -1. Create a new PHP file with the naming convention: +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 + 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, 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: ``` YYYYMMDD_HHMMSS_short_description.php @@ -19,7 +30,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 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 -| 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) | +| `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`. @@ -61,4 +86,6 @@ 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 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.