Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .claude/skills/add-db-migration/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<YYYYMMDD_HHMMSS>_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=<filename>.php # runs just this one, whatever its recorded state
wp spck migrate --only=<filename>.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/<filename>.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.
2 changes: 2 additions & 0 deletions .github/workflows/deploy-future.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions docs/setup/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<YYYYMMDD_HHMMSS>.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.*`).
Expand Down Expand Up @@ -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 <app>` or `pm2 start ecosystem.config.js --only <app>`. |
| `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.** |

Expand Down
22 changes: 4 additions & 18 deletions generators/lang-migration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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() {
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -140,9 +131,7 @@ async function prompt() {
}

return { migrationType, defaultLocale, additionalLocales };
} finally {
rl.close();
}
});
}

function readConfigs() {
Expand Down Expand Up @@ -357,7 +346,4 @@ async function main() {
}
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
run(main);
44 changes: 44 additions & 0 deletions generators/lib.js
Original file line number Diff line number Diff line change
@@ -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 };
132 changes: 132 additions & 0 deletions generators/migration.js
Original file line number Diff line number Diff line change
@@ -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/<YYYYMMDD_HHMMSS>_<slug>.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);
Loading