diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml new file mode 100644 index 0000000..451935b --- /dev/null +++ b/.github/workflows/atomic-deploy.yaml @@ -0,0 +1,83 @@ +name: Atomic Deploy + +on: + workflow_call: + inputs: + ssh-host: + type: string + description: "SSH host to connect to" + required: true + ssh-user: + type: string + description: "SSH user to connect with" + required: false + default: piecode + ssh-port: + type: number + description: "SSH port to connect to" + required: false + default: 22 + wp-root: + type: string + description: "Absolute path to the WordPress root on the remote server (must start with /)" + required: true + components: + type: string + description: "Newline-separated list of components to deploy, one per line in type:name format (e.g. plugins:my-plugin)" + required: true + releases-dir: + type: string + description: "Absolute path to the releases directory on the server. Defaults to a 'releases' sibling of wp-root when not set." + required: false + default: "" + secrets: + SSH_PRIVATE_KEY: + description: "SSH private key" + required: true + SMTP_SERVER: + description: "SMTP server for failure notifications" + required: false + SMTP_USERNAME: + description: "SMTP username" + required: false + SMTP_PASSWORD: + description: "SMTP password" + required: false + NOTIFY_EMAIL: + description: "Override recipient email (defaults to #uptime_alerts Slack channel)" + required: false + +concurrency: + group: atomic-deploy-${{ inputs.ssh-host }}-${{ inputs.wp-root }} + cancel-in-progress: false + +jobs: + atomic_deploy: + runs-on: ubuntu-latest + steps: + + - name: Compute short SHA + id: sha + shell: bash + run: echo "short_sha=${GITHUB_SHA:0:8}" >> $GITHUB_OUTPUT + + - uses: pie/.github/actions/add-ssh-config@feature/add-symlink-and-sql-flows + name: Add SSH key to runner + with: + ssh-user: ${{inputs.ssh-user}} + ssh-port: ${{inputs.ssh-port}} + ssh-host: ${{inputs.ssh-host}} + ssh-key: ${{secrets.SSH_PRIVATE_KEY}} + + - uses: pie/.github/actions/swap-and-migrate@feature/add-symlink-and-sql-flows + name: Run atomic swap and migrations + with: + wp-root: ${{inputs.wp-root}} + git-sha: ${{ steps.sha.outputs.short_sha }} + repo-name: ${{github.event.repository.name}} + components: ${{inputs.components}} + releases-dir: ${{inputs.releases-dir}} + notify-email: ${{secrets.NOTIFY_EMAIL || 'uptime_alerts-aaaaabagrbjrarifmw25mquney@piecode.slack.com'}} + smtp-server: ${{secrets.SMTP_SERVER}} + smtp-username: ${{secrets.SMTP_USERNAME}} + smtp-password: ${{secrets.SMTP_PASSWORD}} diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index dd8ad6b..19e843e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: pie/.github/actions/add-ssh-config@main + - uses: pie/.github/actions/add-ssh-config@feature/add-symlink-and-sql-flows name: Add SSH Key to this runner if: ${{ inputs.sshpass == 'false' }} with: @@ -85,7 +85,7 @@ jobs: ssh-host: ${{inputs.ssh-host}} ssh-key: ${{secrets.SSH_PRIVATE_KEY}} - - uses: pie/.github/actions/add-ssh-pass@main + - uses: pie/.github/actions/add-ssh-pass@feature/add-symlink-and-sql-flows name: Add SSH Pass to this runner if: ${{ inputs.sshpass != 'false' }} with: @@ -94,7 +94,7 @@ jobs: ssh-host: ${{inputs.ssh-host}} ssh-pass: ${{secrets.SSH_PRIVATE_KEY}} - - uses: pie/.github/actions/deploy-via-rsync@main + - uses: pie/.github/actions/deploy-via-rsync@feature/add-symlink-and-sql-flows name: Deploying to ${{inputs.destination-path}} on remote with: ssh-port: ${{inputs.ssh-port}} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fa7f44b..a01f050 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -66,25 +66,14 @@ jobs: git push - name: Check for ignore file - id: check_files - uses: andstor/file-existence-action@v2 - with: - files: ".zipignore" - - - name: File exists - if: steps.check_files.outputs.files_exists == 'true' - shell: bash - # Only runs if all of the files exists - run: | - sed -i '/^[[:space:]]*$/d' .zipignore - sed 's/^/${{ github.event.repository.name }}\//' .zipignore > ../.zipignore - - - name: File does not exist - if: steps.check_files.outputs.files_exists != 'true' shell: bash - # Only runs if any of the files does not exist run: | - touch ../.zipignore + if [ -f ".zipignore" ]; then + sed -i '/^[[:space:]]*$/d' .zipignore + sed 's/^/${{ github.event.repository.name }}\//' .zipignore > ../.zipignore + else + touch ../.zipignore + fi # This step creates a zip of the plugin which can be used for installation, and by the plugin updater. - name: Build required zip artifact diff --git a/.github/workflows/setup.yaml b/.github/workflows/setup.yaml new file mode 100644 index 0000000..264a2c3 --- /dev/null +++ b/.github/workflows/setup.yaml @@ -0,0 +1,18 @@ +name: Setup + +on: + workflow_call: + outputs: + short-sha: + description: "Short (8-character) git commit SHA for use in release directory paths" + value: ${{ jobs.setup.outputs.short_sha }} + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + short_sha: ${{ steps.sha.outputs.short_sha }} + steps: + - id: sha + shell: bash + run: echo "short_sha=${GITHUB_SHA:0:8}" >> $GITHUB_OUTPUT diff --git a/README.md b/README.md index b71dd4a..fa074ee 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,192 @@ -# Github Workflows for PIE.co.de +# GitHub Workflows for PIE.co.de -This repository contains some re-usable workflows and actions for managing repository deployment +This repository contains reusable workflows and composite actions for managing repository deployment. ## Workflows +### Atomic Deploy + +Deploys components to a release directory keyed by the short (8-character) git commit SHA, then atomically swaps them into place and runs any pending database migrations. When migrations are pending, the database work and component swap are performed inside a maintenance window. When there are no pending migrations, components are swapped with no downtime. Supports rollback by resyncing the prior release back to the live directories; when migrations ran, the old prefix tables are dropped during deployment, so a full rollback requires restoring from the pre-deploy backup. + +**How it works:** + +Rsync jobs deploy each component to a release directory keyed by the short SHA (see the `setup` workflow's `short-sha` output). Once all jobs complete, the `atomic_deploy` job SSH's in and runs `swap.sh`, which: + +1. Verifies WP-CLI can reach the database +2. Checks for pending SQL migrations +3. If any: enables maintenance mode → dry-runs the pending migrations against structure-only clones of the live tables (no data, dropped immediately after) and bails out with maintenance mode deactivated if any fail → exports a database backup → copies live tables to a new `{base-prefix}{short-sha}_` prefix → runs migrations against the copy → updates usermeta keys and option names to the new prefix → switches `wp-config.php` to the new prefix → drops old tables +4. Rsyncs each component from the release directory to a hidden staging path, then atomically renames it into place +5. Prunes releases older than 1 prior +6. If migrations ran, prunes older database backups, keeping only the one from this deploy + +Failures are handled based on how far the deploy got: + +- **Before `wp-config.php` or components change** — maintenance mode is deactivated automatically and the site recovers on the previous version. A notification is sent with subject *Deploy failed, site recovered*. +- **After either live change begins** — maintenance mode stays on to prevent the site returning in a broken state. A notification is sent with subject *URGENT: Site in maintenance mode*, including instructions for manual verification. + +**Server directory structure:** + +`releases/` and `db-backups/` are created inside `wp-root` — not a sibling of it — because some hosts don't grant the deploy user write access above the web root. See **Requirements** below for the access rule this requires. + +``` +/home/piecode/site/public_html/ ← WordPress root +├── releases/ +│ ├── {current-sha}/ ← new deploy lands here via rsync +│ │ ├── my-plugin/ +│ │ ├── my-theme/ +│ │ └── migrations/ +│ └── {previous-sha}/ ← kept for rollback +├── db-backups/ ← this deploy's pre-migration export only (when migrations run) +└── wp-content/ + ├── plugins/ + │ └── my-plugin/ ← files copied from releases/{sha}/my-plugin/ + └── themes/ + └── my-theme/ ← files copied from releases/{sha}/my-theme/ +``` + +**Requirements:** + +Before running this workflow, block public HTTP access to `releases/` and `db-backups/` under `wp-root`: + +- **Apache** — create `releases/.htaccess` and `db-backups/.htaccess`, each containing: + ```apache + Require all denied + ``` +- **Nginx** — add to the site's server block: + ```nginx + location ~ ^/(releases|db-backups)/ { deny all; } + ``` + +**Inputs:** + +- `ssh-host`: SSH host. Required. +- `wp-root`: Absolute path to the WordPress root on the server. Required. Must start with `/`. +- `components`: Newline-separated list of components in `type:name` format. Required. +- `ssh-port`: SSH port. Optional, default is `22`. +- `ssh-user`: SSH user. Optional, default is `piecode`. + +**Secrets:** + +- `SSH_PRIVATE_KEY`: SSH private key. Required. +- `SMTP_SERVER`: SMTP server for failure notifications. Optional — set at organisation level. +- `SMTP_USERNAME`: SMTP username. Optional — set at organisation level. +- `SMTP_PASSWORD`: SMTP password. Optional — set at organisation level. +- `NOTIFY_EMAIL`: Override the notification recipient. Optional — defaults to `#uptime_alerts` Slack channel. + +Failure notifications are sent via [`dawidd6/action-send-mail`](https://github.com/dawidd6/action-send-mail), pinned to a commit SHA rather than `@v3` — it runs with the SMTP secrets above, so a moved tag would run different code with those credentials with no diff to review first. Bumping it is a deliberate action: resolve the new tag to a SHA and update both the workflow and its inline comment. + +**Example:** + +```yaml +name: Deploy to Production +on: + push: + branches: + - production +jobs: + setup: + uses: pie/.github/.github/workflows/setup.yaml@main + + deploy_plugin: + needs: setup + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + destination-path: /home/piecode/site/public_html/releases/${{ needs.setup.outputs.short-sha }}/my-plugin + npm: true + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + deploy_theme: + needs: setup + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + destination-path: /home/piecode/site/public_html/releases/${{ needs.setup.outputs.short-sha }}/my-theme + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + deploy_migrations: + needs: setup + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + source-path: migrations/ + destination-path: /home/piecode/site/public_html/releases/${{ needs.setup.outputs.short-sha }}/migrations + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + atomic_deploy: + needs: [deploy_plugin, deploy_theme, deploy_migrations] + uses: pie/.github/.github/workflows/atomic-deploy.yaml@main + with: + ssh-host: example.com + wp-root: /home/piecode/site/public_html + components: | + plugins:my-plugin + themes:my-theme + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + SMTP_SERVER: ${{secrets.SMTP_SERVER}} + SMTP_USERNAME: ${{secrets.SMTP_USERNAME}} + SMTP_PASSWORD: ${{secrets.SMTP_PASSWORD}} +``` + +**Rollback:** + +If no migrations ran, resync each component from the prior release back to the live directory: + +```bash +WP_ROOT=/home/piecode/site/public_html +RELEASES=/home/piecode/site/public_html/releases +PRIOR=$(ls -dt "$RELEASES"/*/ | sed -n '2p') + +rsync -a --delete "${PRIOR}my-plugin/" "$WP_ROOT/wp-content/plugins/my-plugin/" +rsync -a --delete "${PRIOR}my-theme/" "$WP_ROOT/wp-content/themes/my-theme/" + +wp cache flush --path="$WP_ROOT" +``` + +If migrations ran, the old prefix tables were dropped after the prefix switch — rolling back the code alone leaves it running against the migrated schema, which may or may not be compatible. A full rollback requires restoring the database from the pre-deploy backup in `db-backups/` and reverting `table_prefix` in `wp-config.php` to the previous value. + +If the failure notification subject says *URGENT: Site in maintenance mode*, the deploy failed after live changes began. Before deactivating maintenance mode, verify the table prefix and component directories are in a consistent state — the notification email includes the exact commands to run. + +--- + ### Deploy via Rsync -This workflow deploys to a remote server using rsync. +Deploys to a remote server using rsync, supporting both SSH key and password-based authentication. -**Usage Notes:** +**Setup:** -- Generate a new Keypair for your repository if you haven't already - https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent#generating-a-new-ssh-key -- Add SSH_PRIVATE_KEY to your repository secrets. -- Add SSH_PUBLIC_KEY to your repository variables. -- Add an `.rsyncignore` file to the root of your repo listing files which should not be deployed. +- Generate an SSH keypair for your repository if you haven't already — [GitHub docs](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent#generating-a-new-ssh-key) +- Add `SSH_PRIVATE_KEY` to your repository secrets (used as the SSH password when `sshpass: true`). +- Add `SSH_PUBLIC_KEY` to your repository variables. +- Add an `.rsyncignore` file to the root of your repo listing files that should not be deployed. **Inputs:** - `ssh-host`: The SSH host. Required. - `destination-path`: The path on the remote server to deploy to. Required. -- `source-path`: The path within the repo to deploy the files from. Optional, default is `.`. -- `ssh-port`: The SSH port. Optional, default is 22. +- `source-path`: The path within the repo to deploy files from. Optional, default is `.`. +- `working-directory`: Working directory to run commands in. Optional, default is `.`. +- `ssh-port`: The SSH port. Optional, default is `22`. - `ssh-user`: The SSH user. Optional, default is `piecode`. -- `rsync-args`: Additional arguments to pass to rsync. Optional, default is `--no-perms --no-times --no-owner --delete-after`. -- `rsync-flags`: Flags to pass to the rsync command. Optional, default is `-aqP`. -- `composer`: boolean flag if a composer install is required. Optional, defaults to `false`. +- `sshpass`: Use password-based auth (sshpass) instead of an SSH key. Optional, default is `false`. When `true`, `SSH_PRIVATE_KEY` is used as the password. +- `rsync-args`: Additional arguments to pass to rsync. Optional, default is `--no-perms --no-times --no-owner --delete-after --delete-excluded`. +- `composer`: Run `composer install` before deploying. Optional, default is `false`. - `composer-args`: Additional arguments to pass to composer. Optional, default is `--no-dev --no-interaction --no-progress --optimize-autoloader --prefer-dist`. -- `npm`: boolean flag if an npm install is required. Optional, defaults to `false`. -- `node_version`: Version of Node required for the build. Optional, defaults to `18`. -- `npm-run-command`: Commands required to run after npm install. Optional, defaults to `npm run build`. +- `npm`: Run `npm install` and build before deploying. Optional, default is `false`. +- `node_version`: Node.js version for the build. Optional, default is `18`. +- `npm-run-command`: Command to run after `npm install`. Optional, default is `npm run build`. -**Example Workflow:** +**Secrets:** -``` +- `SSH_PRIVATE_KEY`: SSH private key (or password when `sshpass: true`). Required. + +**Example:** + +```yaml name: Deploy to WP Engine on: workflow_dispatch: @@ -47,24 +201,65 @@ jobs: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} ``` -### Synchronise Environments +--- + +### Deploy via FTP + +Deploys to a remote server over FTP with optional Composer and npm build steps. + +**Inputs:** + +- `ftp-host`: The FTP server host. Required. +- `ftp-username`: The FTP username. Required. +- `destination-path`: The destination path on the remote server. Required. +- `ftp-port`: The FTP port. Optional, default is `21`. +- `ftp-exclude`: Glob patterns of files to exclude. Optional, default excludes `.git*` and `node_modules`. +- `composer`: Run `composer install` before deploying. Optional, default is `false`. +- `npm`: Run `npm install` and build before deploying. Optional, default is `false`. +- `node_version`: Node.js version for the build. Optional, default is `18`. + +**Secrets:** + +- `FTP_PASSWORD`: FTP password. Required. + +**Example:** + +```yaml +name: Deploy via FTP +on: + workflow_dispatch: +jobs: + deploy: + uses: pie/.github/.github/workflows/deploy-via-ftp.yaml@main + with: + ftp-host: ftp.example.com + ftp-username: myuser + destination-path: /public_html/my-plugin + secrets: + FTP_PASSWORD: ${{secrets.FTP_PASSWORD}} +``` + +--- -**Usage Notes:** +### Synchronise Environments -This workflow can be run against any branch in order to log into the remote server and run a script to copy one environment into another. In the future we will run these scripts from within the workflow runner +Logs into a remote server over SSH and runs a script to copy one environment into another. **Inputs:** - `ssh-host`: The SSH host. Required. -- `synchronisation-script`: Remote path to the Synchronisation script. Required. -- `ssh-port`: The SSH port. Optional, default is 22. +- `synchronisation-script`: Remote path to the synchronisation script. Required. +- `ssh-port`: The SSH port. Optional, default is `22`. - `ssh-user`: The SSH user. Optional, default is `piecode`. -**Example Workflow:** +**Secrets:** -``` -name: Synchronise Development Server +- `SSH_PRIVATE_KEY`: SSH private key. Required. +**Example:** + +```yaml +name: Synchronise Development Server on: workflow_dispatch jobs: run-synchronisation-workflow: @@ -75,3 +270,86 @@ jobs: secrets: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} ``` + +--- + +### Create Release + +Checks whether a release is required based on PR labels, bumps version numbers across key files, packages a zip artifact, and publishes a GitHub release. + +**Trigger:** Label a pull request with `release:major`, `release:minor`, or `release:patch` before merging to `main`. + +**What it does:** + +1. Uses [release-on-push-action](https://github.com/rymndhng/release-on-push-action) in dry-run mode to determine whether a release is needed and what the next version should be. +2. If a release is required, checks out `main` and bumps the version string via `sed` in: + - `package.json` + - `update.json` (version field and download URL) + - `{repository-name}.php` (Version header comment) + - `changelog.md` (Unreleased section) +3. Commits and pushes the version bump. +4. Creates a zip of the repository root, respecting `.zipignore` if present. +5. Publishes a GitHub release with the version tag, auto-generated release notes, and the zip as a downloadable artifact. + +**Example:** + +```yaml +name: Release +on: + push: + branches: + - main +jobs: + release: + uses: pie/.github/.github/workflows/release.yaml@main +``` + +--- + +## Actions + +These composite actions are used internally by the workflows above but can also be referenced directly. + +| Action | Description | +|---|---| +| `add-ssh-config` | Adds an SSH private key to the runner and creates a `server` host alias for key-based auth | +| `add-ssh-pass` | Installs sshpass and configures password-based SSH authentication | +| `deploy-via-rsync` | Runs an optional Composer/npm build then deploys files via rsync | +| `deploy-via-ftp` | Runs an optional Composer/npm build then deploys files via FTP | +| `swap-and-migrate` | Runs DB migrations and atomic component swap in a single SSH session | +| `synchronise-remote` | Executes a synchronisation script on a remote server over SSH | +| `verify-branch-is-correct` | Fails the job if the current branch does not match the expected branch (default: `production`) | +| `verify-branch-is-up-to-date` | Fails the job if the current branch is behind the target branch (default: `main`) | + +--- + +## Templates + +### SQL Migrations + +Copy `templates/migrations/` into your project to get the `migrations/queries/` directory structure. No scripts are needed per-project — `swap.sh` and `migrate.sh` are bundled with the action and uploaded to the server automatically on each deploy. + +The calling workflow should rsync `migrations/` to `releases/${{ github.sha }}/migrations` and pass the component list to the `atomic-deploy` workflow: + +```yaml +components: | + plugins:my-plugin + themes:my-theme +``` + +**Naming convention:** `{four-digit-number}_{description}.sql` — the number controls execution order. Gaps are fine. Never renumber or delete a migration once committed. + +``` +migrations/queries/ +├── 0001_add_source_column.sql +└── 0002_backfill_source_column.sql +``` + +**Table prefix placeholder:** Use `__WP_PREFIX__` in migration files wherever a table prefix is needed. It is replaced with the correct prefix at deploy time. Never hardcode `wp_` or any other prefix — a global string replacement would risk corrupting string literals or comments that happen to contain the prefix. + +```sql +-- 0001_add_source_column.sql +ALTER TABLE __WP_PREFIX__posts ADD COLUMN source VARCHAR(255) DEFAULT NULL; +``` + +Migrations are tracked per-project in a table named `{repo_name}_migrations` (derived automatically). The table is created on first run if it does not exist. diff --git a/actions/deploy-via-rsync/action.yml b/actions/deploy-via-rsync/action.yml index c1cc490..a292ac0 100644 --- a/actions/deploy-via-rsync/action.yml +++ b/actions/deploy-via-rsync/action.yml @@ -92,11 +92,18 @@ runs: shell: bash run: | if [ -f ".rsyncignore" ]; then - echo EXCLUDE_FROM=" --exclude-from=.rsyncignore " >> "$GITHUB_ENV" + echo "EXCLUDE_FROM= --exclude-from=.rsyncignore " >> "$GITHUB_ENV" else - echo EXCLUDE_FROM="" >> "$GITHUB_ENV" + echo "EXCLUDE_FROM=" >> "$GITHUB_ENV" fi + - name: Create destination directory (pubkey) + if: inputs.sshpass != 'true' + shell: bash + env: + DEST: ${{ inputs.destination-path }} + run: ssh server "mkdir -p $(printf '%q' "$DEST")" + - name: Rsync to the remote with pubkey if: inputs.sshpass != 'true' run: rsync ${{inputs.rsync-flags}} ${{inputs.rsync-args}} ${{ env.EXCLUDE_FROM }} ${{inputs.source-path}} server:${{inputs.destination-path}} @@ -118,6 +125,13 @@ runs: fi shell: bash + - name: Create destination directory (sshpass) + if: inputs.sshpass == 'true' + shell: bash + env: + DEST: ${{ inputs.destination-path }} + run: sshpass -e ssh -o StrictHostKeyChecking=no -p ${{inputs.ssh-port}} ${{inputs.ssh-user}}@${{inputs.ssh-host}} "mkdir -p $(printf '%q' "$DEST")" + - name: Rsync to the remote with sshpass if: inputs.sshpass == 'true' run: sshpass -e rsync ${{inputs.rsync-flags}} ${{inputs.rsync-args}} ${{ env.EXCLUDE_FROM }} -e 'ssh -o StrictHostKeyChecking=no -p ${{inputs.ssh-port}}' ${{inputs.source-path}} ${{inputs.ssh-user}}@${{inputs.ssh-host}}:${{inputs.destination-path}} diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml new file mode 100644 index 0000000..ea1041f --- /dev/null +++ b/actions/swap-and-migrate/action.yml @@ -0,0 +1,173 @@ +name: Swap and Migrate +description: "Performs maintenance mode, database migrations, and atomic component swap in a single SSH session" + +inputs: + wp-root: + description: "Absolute path to the WordPress root on the remote server (must start with /)" + required: true + git-sha: + description: "Git commit SHA for the current deployment" + required: true + repo-name: + description: "Repository name used to derive the migrations tracking table name" + required: true + components: + description: "Newline-separated list of components to deploy, one per line in type:name format (e.g. plugins:my-plugin)" + required: true + releases-dir: + description: "Absolute path to the releases directory on the server. Defaults to a 'releases' subdirectory inside wp-root when not set." + required: false + default: "" + notify-email: + description: "Email address to notify on failure" + required: false + default: "" + smtp-server: + description: "SMTP server address" + required: false + default: "" + smtp-username: + description: "SMTP username" + required: false + default: "" + smtp-password: + description: "SMTP password" + required: false + default: "" + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + run: | + if [[ "$WP_ROOT" != '/'* ]]; then + echo "Error: wp-root must be an absolute path starting with / (e.g. /home/piecode/site/public_html)." >&2 + exit 1 + fi + + # A relative override here ends up relative to whatever directory the + # SSH login session lands in, not wp-root — and swap.sh's pruning step + # runs rm -rf against whatever it finds there, unrelated first-level + # directories included. + if [ -n "$RELEASES_DIR_INPUT" ] && [[ "$RELEASES_DIR_INPUT" != '/'* ]]; then + echo "Error: releases-dir must be an absolute path starting with / (e.g. /home/piecode/site/public_html/releases)." >&2 + exit 1 + fi + + - name: Upload swap scripts to server + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + GIT_SHA: ${{ inputs.git-sha }} + DEPLOY_COMPONENTS: ${{ inputs.components }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + run: | + if [ -n "$RELEASES_DIR_INPUT" ]; then + RELEASES_DIR="$RELEASES_DIR_INPUT" + else + RELEASES_DIR="$WP_ROOT/releases" + fi + MIGRATIONS_DIR="$RELEASES_DIR/$GIT_SHA/migrations" + MIGRATIONS_DIR_Q=$(printf '%q' "$MIGRATIONS_DIR") + ssh server "mkdir -p $MIGRATIONS_DIR_Q" + scp "$GITHUB_ACTION_PATH/scripts/swap.sh" "server:$MIGRATIONS_DIR/swap.sh" + scp "$GITHUB_ACTION_PATH/scripts/migrate.sh" "server:$MIGRATIONS_DIR/migrate.sh" + printf '%s' "$DEPLOY_COMPONENTS" | ssh server "cat > $MIGRATIONS_DIR_Q/components.txt" + + - name: Run atomic swap and migrations + id: swap + continue-on-error: true + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + GIT_SHA: ${{ inputs.git-sha }} + REPO_NAME: ${{ inputs.repo-name }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + run: | + set +e + WP_ROOT_Q=$(printf '%q' "$WP_ROOT") + GIT_SHA_Q=$(printf '%q' "$GIT_SHA") + REPO_NAME_Q=$(printf '%q' "$REPO_NAME") + if [ -n "$RELEASES_DIR_INPUT" ]; then + RELEASES_DIR="$RELEASES_DIR_INPUT" + else + RELEASES_DIR="$WP_ROOT/releases" + fi + RELEASES_DIR_Q=$(printf '%q' "$RELEASES_DIR") + SWAP_SCRIPT="$RELEASES_DIR/$GIT_SHA/migrations/swap.sh" + SWAP_SCRIPT_Q=$(printf '%q' "$SWAP_SCRIPT") + ssh server "env WP_ROOT=$WP_ROOT_Q GIT_SHA=$GIT_SHA_Q REPO_NAME=$REPO_NAME_Q RELEASES_DIR=$RELEASES_DIR_Q bash $SWAP_SCRIPT_Q" + SSH_EXIT=$? + set -e + echo "ssh_exit=${SSH_EXIT}" >> "$GITHUB_OUTPUT" + exit $SSH_EXIT + + # Exit code 1: failed before live changes — maintenance mode was deactivated, + # site is running on the previous version. + - name: Send failure notification — site recovered + if: steps.swap.outcome == 'failure' && steps.swap.outputs.ssh_exit == '1' && inputs.smtp-server != '' && inputs.smtp-username != '' && inputs.smtp-password != '' && inputs.notify-email != '' + # Pinned to a commit SHA rather than @v3 — this action runs with SMTP + # credentials as secrets, so a tag getting repointed (maintainer + # compromise or otherwise) would run different code with those + # credentials on the next deploy, with no diff to review here first. + # To intentionally pick up an update, resolve the new v3 tag to a SHA + # and replace both the hash and this comment. + uses: dawidd6/action-send-mail@4226df7daafa6fc901a43789c49bf7ab309066e7 # v3 + with: + server_address: ${{ inputs.smtp-server }} + server_port: 465 + secure: true + username: ${{ inputs.smtp-username }} + password: ${{ inputs.smtp-password }} + to: ${{ inputs.notify-email }} + from: GitHub Actions + subject: "Deploy failed, site recovered — ${{ github.repository }}" + html_body: | +

Deploy failed — site recovered

+

The deploy failed before any live changes were made. Maintenance mode was deactivated automatically. The site is running on the previous version.

+

Repository: ${{ github.repository }}
+ SHA: ${{ inputs.git-sha }}
+ Exit code: ${{ steps.swap.outputs.ssh_exit }}

+

View workflow run for full log

+ + # Exit code 2: failed after live changes began — site is in maintenance mode, + # wp-config.php's table_prefix or a component directory may be in a partially-updated state. + - name: Send urgent notification — site in maintenance mode + if: steps.swap.outcome == 'failure' && steps.swap.outputs.ssh_exit == '2' && inputs.smtp-server != '' && inputs.smtp-username != '' && inputs.smtp-password != '' && inputs.notify-email != '' + # Pinned to a commit SHA rather than @v3 — this action runs with SMTP + # credentials as secrets, so a tag getting repointed (maintainer + # compromise or otherwise) would run different code with those + # credentials on the next deploy, with no diff to review here first. + # To intentionally pick up an update, resolve the new v3 tag to a SHA + # and replace both the hash and this comment. + uses: dawidd6/action-send-mail@4226df7daafa6fc901a43789c49bf7ab309066e7 # v3 + with: + server_address: ${{ inputs.smtp-server }} + server_port: 465 + secure: true + username: ${{ inputs.smtp-username }} + password: ${{ inputs.smtp-password }} + to: ${{ inputs.notify-email }} + from: GitHub Actions + subject: "URGENT: Site in maintenance mode — ${{ github.repository }}" + html_body: | +

⚠️ Urgent: site is in maintenance mode

+

The deploy failed after live changes began. The site is currently in maintenance mode and requires manual intervention.

+

Before deactivating maintenance mode, SSH into the server and verify:

+
    +
  1. Check the active table prefix:
    wp config get table_prefix --path="${{ inputs.wp-root }}"
  2. +
  3. Check component directories in wp-content are in a consistent state:
    ls -la ${{ inputs.wp-root }}/wp-content/plugins/ ${{ inputs.wp-root }}/wp-content/themes/
  4. +
+

Once the state is confirmed safe, deactivate maintenance mode:
wp maintenance-mode deactivate --path="${{ inputs.wp-root }}"

+

Repository: ${{ github.repository }}
+ SHA: ${{ inputs.git-sha }}

+

View workflow run for full log

+ + - name: Fail the job + if: steps.swap.outcome == 'failure' + shell: bash + run: exit 1 diff --git a/actions/swap-and-migrate/scripts/migrate.sh b/actions/swap-and-migrate/scripts/migrate.sh new file mode 100644 index 0000000..2b07f43 --- /dev/null +++ b/actions/swap-and-migrate/scripts/migrate.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# migrate.sh — Database migration runner +# +# Uploaded to the server by the swap-and-migrate action on each deploy. +# Do not copy or edit this file per-project — changes belong in the action. +# +# Called as a subprocess from swap.sh during an atomic deploy. Applies pending +# SQL migrations against the copied tables (NEW_PREFIX), so the live database +# is never touched until the prefix switch succeeds in swap.sh. +# +# Migration files must use __WP_PREFIX__ as a placeholder for the table prefix. +# This token is replaced with NEW_PREFIX before execution, ensuring only +# explicit prefix references are rewritten — never string literals or comments +# that happen to contain the prefix substring. +# +# Example: ALTER TABLE __WP_PREFIX__posts ADD COLUMN source VARCHAR(255); +# +# Injected by swap.sh: +# WP_ROOT Absolute path to the WordPress root +# MIGRATIONS_TABLE Tracking table name (pre-computed by swap.sh) +# NEW_PREFIX New prefix to target (e.g. wp_a1b2c3d4_) +# ============================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +QUERIES_DIR="$SCRIPT_DIR/queries" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# ============================================================================== +# Step 1: Ensure tracking table exists +# ============================================================================== + +wp db query " + CREATE TABLE IF NOT EXISTS \`$MIGRATIONS_TABLE\` ( + id INT AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) +" --path="$WP_ROOT" + +# ============================================================================== +# Step 2: Find pending migrations +# ============================================================================== + +if [ ! -d "$QUERIES_DIR" ]; then + log "No queries directory found — nothing to migrate" + exit 0 +fi + +APPLIED=$(wp db query \ + "SELECT filename FROM \`$MIGRATIONS_TABLE\`" \ + --path="$WP_ROOT" --skip-column-names 2>/dev/null || echo "") + +PENDING=() +while IFS= read -r SQL_FILE; do + FILENAME=$(basename "$SQL_FILE") + if ! echo "$APPLIED" | grep -qxF "$FILENAME"; then + PENDING+=("$SQL_FILE") + fi +done < <(find "$QUERIES_DIR" -maxdepth 1 -name "*.sql" | sort) + +if [ "${#PENDING[@]}" -eq 0 ]; then + log "No pending migrations" + exit 0 +fi + +log "${#PENDING[@]} migration(s) to apply" + +# ============================================================================== +# Step 3: Apply pending migrations against the copied tables +# ============================================================================== + +for SQL_FILE in "${PENDING[@]}"; do + FILENAME=$(basename "$SQL_FILE") + log "Applying $FILENAME" + + sed "s/__WP_PREFIX__/${NEW_PREFIX}/g" "$SQL_FILE" \ + | wp db query --path="$WP_ROOT" + + SAFE_FILENAME=$(printf '%s' "$FILENAME" | sed "s/'/''/g") + wp db query \ + "INSERT INTO \`$MIGRATIONS_TABLE\` (filename) VALUES ('$SAFE_FILENAME')" \ + --path="$WP_ROOT" + + log " Applied: $FILENAME" +done + +log "All migrations applied" diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh new file mode 100644 index 0000000..661bb51 --- /dev/null +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -0,0 +1,500 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# swap.sh — Atomic deploy: migrations + component swap +# +# Uploaded to the server by the swap-and-migrate action on each deploy. +# Do not copy or edit this file per-project — changes belong in the action. +# +# Injected by the action: +# WP_ROOT Absolute path to the WordPress root (e.g. /home/piecode/site/public_html) +# GIT_SHA Git commit SHA for this deployment (8-character short SHA is acceptable) +# REPO_NAME GitHub repository name (used to derive migrations table name) +# +# Components are read from components.txt in the same directory, written by the +# action before this script runs. Format: one "type:name" entry per line. +# ============================================================================== + +SHORT_SHA="${GIT_SHA:0:8}" +RELEASES_DIR="${RELEASES_DIR:-$WP_ROOT/releases}" +NEW_RELEASE_DIR="$RELEASES_DIR/$GIT_SHA" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATE_SCRIPT="$SCRIPT_DIR/migrate.sh" +QUERIES_DIR="$SCRIPT_DIR/queries" +HAS_MIGRATIONS=false +MAINTENANCE_ACTIVE=false +SAFE_TO_RECOVER=true + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# Fires on any non-zero exit via set -euo pipefail. +# +# If maintenance mode was never activated, nothing to do. +# If activated and we haven't yet touched wp-config or components, it is safe to +# deactivate — the live site is unmodified. Exit 1. +# If activated and live changes have begun (SAFE_TO_RECOVER=false), the site +# must stay in maintenance mode until manually verified. Exit 2. +cleanup() { + local EXIT_CODE=$? + [ $EXIT_CODE -eq 0 ] && return + if [ "$MAINTENANCE_ACTIVE" = true ]; then + if [ "$SAFE_TO_RECOVER" = true ]; then + log "Deploy failed before live changes — deactivating maintenance mode" + wp maintenance-mode deactivate --path="$WP_ROOT" || true + exit 1 + else + log "ERROR: Deploy failed after live changes began — site is in maintenance mode" + log "ERROR: Before deactivating maintenance mode, verify:" + log "ERROR: wp config get table_prefix --path=\"$WP_ROOT\"" + log "ERROR: ls -la $WP_ROOT/wp-content/plugins/ $WP_ROOT/wp-content/themes/" + exit 2 + fi + fi + exit $EXIT_CODE +} +trap cleanup EXIT + +# ============================================================================== +# Step 1: Pre-flight checks +# ============================================================================== + +log "Atomic deploy starting — SHA: $GIT_SHA" + +if [[ "$WP_ROOT" != '/'* ]]; then + echo "ERROR: WP_ROOT must be an absolute path starting with / (e.g. /home/piecode/site/public_html)." >&2 + exit 1 +fi + +if ! command -v wp &>/dev/null; then + echo "ERROR: wp-cli is not available on this server" >&2 + exit 1 +fi + +log "Verifying database connectivity" +wp db check --path="$WP_ROOT" + +CURRENT_PREFIX=$(wp config get table_prefix --path="$WP_ROOT") + +# CURRENT_PREFIX is interpolated into SQL string literals and identifiers below +# (directly, and via BASE_PREFIX/NEW_PREFIX derived from it). WordPress's own +# installer already restricts table_prefix to this character set — enforcing +# it here means a misconfigured wp-config.php fails cleanly instead of +# corrupting a query or behaving like injected SQL. +if [[ ! "$CURRENT_PREFIX" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "ERROR: table_prefix '$CURRENT_PREFIX' contains unexpected characters — refusing to use it in SQL. Expected only letters, digits, and underscores, not starting with a digit." >&2 + exit 1 +fi + +# Derived unconditionally (not just when migrations are pending) so its +# length is known before REPO_SLUG below is sized against it — NEW_PREFIX +# (used later if migrations do run) is always this plus an 8-char short SHA +# and "_", making it the longer of the two prefixes this repo's migrations +# table name can be built with. +BASE_PREFIX=$(printf '%s' "$CURRENT_PREFIX" | sed 's/[0-9a-f]\{8\}_$//') + +# REPO_SLUG is embedded into the migrations tracking table name under both +# CURRENT_PREFIX and NEW_PREFIX. MySQL caps identifiers at 64 characters, so +# cap REPO_SLUG to whatever's left after the longer (NEW_PREFIX) case, +# instead of a flat cut that ignores the prefix entirely — a flat cap left +# room to overflow 64 as soon as a repo name pushed REPO_SLUG near its limit. +MIGRATIONS_SUFFIX="_migrations" +MAX_SLUG_LEN=$(( 64 - ${#BASE_PREFIX} - ${#SHORT_SHA} - 1 - ${#MIGRATIONS_SUFFIX} )) +if [ "$MAX_SLUG_LEN" -lt 1 ]; then + echo "ERROR: table_prefix '$CURRENT_PREFIX' is too long to derive a migrations table name within MySQL's 64-character identifier limit." >&2 + exit 1 +fi +REPO_SLUG="$(printf '%s' "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' | cut -c1-"$MAX_SLUG_LEN")" + +LIVE_MIGRATIONS_TABLE="${CURRENT_PREFIX}${REPO_SLUG}_migrations" + +if [ ! -d "$NEW_RELEASE_DIR" ]; then + echo "ERROR: Release directory $NEW_RELEASE_DIR not found — did all rsync jobs complete?" >&2 + exit 1 +fi + +COMPONENTS_FILE="$SCRIPT_DIR/components.txt" +if [ ! -f "$COMPONENTS_FILE" ]; then + echo "ERROR: components.txt not found at $COMPONENTS_FILE" >&2 + exit 1 +fi + +readarray -t COMPONENTS < <(grep -v '^[[:space:]]*$' "$COMPONENTS_FILE") + +if [ "${#COMPONENTS[@]}" -eq 0 ]; then + echo "ERROR: No components defined in components.txt" >&2 + exit 1 +fi + +# TYPE and NAME are extracted from each entry below and built into paths that +# are later passed to mv/rm -rf. Restricting to slug-safe characters up front +# rules out a stray '/' or '..' steering those destructive calls outside +# wp-content/, however the entry ended up malformed. +for COMPONENT in "${COMPONENTS[@]}"; do + if [[ ! "$COMPONENT" =~ ^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$ ]]; then + echo "ERROR: Invalid component entry '$COMPONENT' — expected type:name using only letters, digits, hyphens, and underscores." >&2 + exit 1 + fi +done + +log "Validating component release paths" +for COMPONENT in "${COMPONENTS[@]}"; do + NAME="${COMPONENT##*:}" + RELEASE_PATH="$NEW_RELEASE_DIR/$NAME" + if [ ! -d "$RELEASE_PATH" ]; then + echo "ERROR: $RELEASE_PATH not found — did the rsync job for $NAME complete?" >&2 + exit 1 + fi +done + +# ============================================================================== +# Step 2: Detect pending migrations +# ============================================================================== + +PENDING_FILES=() + +if [ -d "$QUERIES_DIR" ]; then + APPLIED=$(wp db query \ + "SELECT filename FROM \`$LIVE_MIGRATIONS_TABLE\`" \ + --path="$WP_ROOT" --skip-column-names 2>/dev/null || echo "") + + while IFS= read -r SQL_FILE; do + FILENAME=$(basename "$SQL_FILE") + if ! echo "$APPLIED" | grep -qxF "$FILENAME"; then + PENDING_FILES+=("$SQL_FILE") + fi + done < <(find "$QUERIES_DIR" -maxdepth 1 -name "*.sql" | sort) +fi + +if [ "${#PENDING_FILES[@]}" -gt 0 ]; then + HAS_MIGRATIONS=true + log "${#PENDING_FILES[@]} pending migration(s) found" +else + log "No pending migrations" +fi + +# ============================================================================== +# Step 3: Database migrations +# +# Prefix derivation and pre-existence check run before the maintenance window +# so a retry collision or bad config fails before any downtime. +# ============================================================================== + +if [ "$HAS_MIGRATIONS" = true ]; then + + # BASE_PREFIX was already derived above (stable base with any previous + # atomic-deploy SHA suffix stripped) so REPO_SLUG could be sized against it. + # e.g. wp_ -> wp_abc12345_; foo_abc12345_ -> foo_ -> foo_def67890_ + NEW_PREFIX="${BASE_PREFIX}${SHORT_SHA}_" + NEW_MIGRATIONS_TABLE="${NEW_PREFIX}${REPO_SLUG}_migrations" + log "Table prefix: '$CURRENT_PREFIX' -> '$NEW_PREFIX'" + + EXISTING_COUNT=$(wp db query \ + "SELECT COUNT(*) FROM information_schema.tables \ + WHERE table_schema = DATABASE() \ + AND LEFT(table_name, CHAR_LENGTH('${NEW_PREFIX}')) = '${NEW_PREFIX}'" \ + --path="$WP_ROOT" --skip-column-names) + + if [ "$EXISTING_COUNT" -gt 0 ]; then + echo "ERROR: Tables with prefix '${NEW_PREFIX}' already exist — a previous deploy attempt may have left partial data." >&2 + echo "ERROR: Drop them before retrying:" >&2 + wp db query \ + "SELECT CONCAT('DROP TABLE \`', table_name, '\`;') \ + FROM information_schema.tables \ + WHERE table_schema = DATABASE() \ + AND LEFT(table_name, CHAR_LENGTH('${NEW_PREFIX}')) = '${NEW_PREFIX}'" \ + --path="$WP_ROOT" --skip-column-names >&2 + exit 1 + fi + + log "Enabling maintenance mode" + wp maintenance-mode activate --path="$WP_ROOT" + MAINTENANCE_ACTIVE=true + + # ------------------------------------------------------------------ + # Dry run: validate pending migration patches before spending time on + # a backup and the full data clone below. Patches are applied to + # structure-only clones of the live tables (columns/indexes, no rows) + # under a throwaway prefix, then those clones are dropped immediately. + # Live data and the real NEW_PREFIX clone are untouched either way — + # a data-dependent patch (e.g. an UPDATE matching on row content) can + # still pass here and fail for other reasons later, since no rows + # exist yet to match against. + # ------------------------------------------------------------------ + log "Dry run: validating ${#PENDING_FILES[@]} pending migration(s) against a structure-only clone" + + DRYRUN_PREFIX="dryrun_${SHORT_SHA}_" + DRYRUN_SOURCE_TABLES=$(wp db query \ + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = DATABASE() \ + AND LEFT(table_name, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}'" \ + --path="$WP_ROOT" --skip-column-names) + + set +e + DRYRUN_FAILED=false + + # Clear any remnants from a previous failed attempt at this SHA, then clone structure only. + while IFS= read -r TABLE; do + [ -z "$TABLE" ] && continue + DRYRUN_TABLE="${DRYRUN_PREFIX}${TABLE#$CURRENT_PREFIX}" + wp db query "DROP TABLE IF EXISTS \`$DRYRUN_TABLE\`" --path="$WP_ROOT" || true + wp db query "CREATE TABLE \`$DRYRUN_TABLE\` LIKE \`$TABLE\`" --path="$WP_ROOT" || DRYRUN_FAILED=true + done <<< "$DRYRUN_SOURCE_TABLES" + + if [ "$DRYRUN_FAILED" = false ]; then + for SQL_FILE in "${PENDING_FILES[@]}"; do + FILENAME=$(basename "$SQL_FILE") + log "Dry run: applying $FILENAME" + if ! sed "s/__WP_PREFIX__/${DRYRUN_PREFIX}/g" "$SQL_FILE" | wp db query --path="$WP_ROOT"; then + echo "ERROR: Dry run failed applying $FILENAME" >&2 + DRYRUN_FAILED=true + break + fi + done + fi + + log "Dry run: cleaning up scratch tables" + while IFS= read -r TABLE; do + [ -z "$TABLE" ] && continue + DRYRUN_TABLE="${DRYRUN_PREFIX}${TABLE#$CURRENT_PREFIX}" + wp db query "DROP TABLE IF EXISTS \`$DRYRUN_TABLE\`" --path="$WP_ROOT" || true + done <<< "$DRYRUN_SOURCE_TABLES" + set -e + + if [ "$DRYRUN_FAILED" = true ]; then + echo "ERROR: Dry run detected a migration failure — bailing out before the database backup. No live data was touched." >&2 + exit 1 + fi + + log "Dry run passed" + + BACKUP_DIR="$(dirname "$RELEASES_DIR")/db-backups" + mkdir -p "$BACKUP_DIR" + BACKUP_FILE="$BACKUP_DIR/pre_deploy_${SHORT_SHA}_$(date +%Y%m%d%H%M%S).sql" + log "Exporting database backup to $BACKUP_FILE" + wp db export "$BACKUP_FILE" --path="$WP_ROOT" + + log "Copying tables from prefix '$CURRENT_PREFIX' to '$NEW_PREFIX'" + + TABLES=$(wp db query \ + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = DATABASE() \ + AND LEFT(table_name, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}'" \ + --path="$WP_ROOT" --skip-column-names) + + while IFS= read -r TABLE; do + [ -z "$TABLE" ] && continue + NEW_TABLE="${NEW_PREFIX}${TABLE#$CURRENT_PREFIX}" + log " $TABLE -> $NEW_TABLE" + wp db query "CREATE TABLE \`$NEW_TABLE\` LIKE \`$TABLE\`" --path="$WP_ROOT" + wp db query "INSERT INTO \`$NEW_TABLE\` SELECT * FROM \`$TABLE\`" --path="$WP_ROOT" + done <<< "$TABLES" + + # CREATE TABLE ... LIKE (above) copies columns and indexes but not foreign + # key constraints or triggers, so both are rebuilt here from + # information_schema before the old tables are dropped for good. Names + # get a "_$SHORT_SHA" suffix — constraint/trigger names are unique per + # schema and the old ones are still around until the drop step below. + # Trigger bodies are copied verbatim: one that only touches its own table + # (the common case, via NEW/OLD) is fully correct, but a body that + # references another prefixed table by name still points at the old + # prefix, since rewriting arbitrary SQL text safely isn't possible here. + log "Recreating foreign keys on new prefix tables" + + # wp db query falls back to printing a generic "Success: Query succeeded" + # status line to stdout when a SELECT matches zero rows, instead of + # nothing — so an empty result from the DDL-building query below can't be + # told apart from real output just by checking for a non-empty capture. + # COUNT(*) always returns exactly one real row, zero matches included, + # so gate on that first and only build/run the DDL query when it's certain + # to have actual rows to return. + FK_COUNT=$(wp db query \ + "SELECT COUNT(*) FROM ( + SELECT kcu.TABLE_NAME + FROM information_schema.KEY_COLUMN_USAGE kcu + WHERE kcu.CONSTRAINT_SCHEMA = DATABASE() + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + AND LEFT(kcu.TABLE_NAME, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}' + GROUP BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME + ) fk_count" \ + --path="$WP_ROOT" --skip-column-names) + + if [ "$FK_COUNT" -gt 0 ]; then + FK_DDL=$(wp db query \ + "SELECT CONCAT( + 'ALTER TABLE \`', new_table, '\` ADD CONSTRAINT \`', new_constraint, '\` ', + 'FOREIGN KEY (', cols, ') REFERENCES \`', new_ref_table, '\` (', ref_cols, ') ', + 'ON DELETE ', delete_rule, ' ON UPDATE ', update_rule, ';' + ) + FROM ( + SELECT + CONCAT('${NEW_PREFIX}', SUBSTRING(kcu.TABLE_NAME, CHAR_LENGTH('${CURRENT_PREFIX}') + 1)) AS new_table, + CONCAT(SUBSTRING(kcu.CONSTRAINT_NAME, 1, 54), '_${SHORT_SHA}') AS new_constraint, + GROUP_CONCAT(CONCAT('\`', kcu.COLUMN_NAME, '\`') ORDER BY kcu.ORDINAL_POSITION) AS cols, + CASE WHEN LEFT(kcu.REFERENCED_TABLE_NAME, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}' + THEN CONCAT('${NEW_PREFIX}', SUBSTRING(kcu.REFERENCED_TABLE_NAME, CHAR_LENGTH('${CURRENT_PREFIX}') + 1)) + ELSE kcu.REFERENCED_TABLE_NAME END AS new_ref_table, + GROUP_CONCAT(CONCAT('\`', kcu.REFERENCED_COLUMN_NAME, '\`') ORDER BY kcu.ORDINAL_POSITION) AS ref_cols, + rc.DELETE_RULE AS delete_rule, + rc.UPDATE_RULE AS update_rule + FROM information_schema.KEY_COLUMN_USAGE kcu + JOIN information_schema.REFERENTIAL_CONSTRAINTS rc + ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND rc.TABLE_NAME = kcu.TABLE_NAME + WHERE kcu.CONSTRAINT_SCHEMA = DATABASE() + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + AND LEFT(kcu.TABLE_NAME, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}' + GROUP BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.REFERENCED_TABLE_NAME, rc.DELETE_RULE, rc.UPDATE_RULE + ) fk" \ + --path="$WP_ROOT" --skip-column-names) + + printf '%s\n' "$FK_DDL" | wp db query --path="$WP_ROOT" + fi + + log "Recreating triggers on new prefix tables" + + TRIGGER_COUNT=$(wp db query \ + "SELECT COUNT(*) FROM information_schema.TRIGGERS + WHERE TRIGGER_SCHEMA = DATABASE() + AND LEFT(EVENT_OBJECT_TABLE, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}'" \ + --path="$WP_ROOT" --skip-column-names) + + if [ "$TRIGGER_COUNT" -gt 0 ]; then + # A trigger body is often a multi-statement BEGIN...END block, which has + # its own semicolons — piped through mysql's stdin (as wp db query does + # here), those would otherwise get split as separate top-level statements + # the same way they would from a plain .sql file. Ending each generated + # statement in '$$' instead of ';', bracketed by DELIMITER changes, is + # MySQL's own documented fix for exactly this. + TRIGGER_DDL=$(wp db query \ + "SELECT CONCAT( + 'CREATE TRIGGER \`', SUBSTRING(TRIGGER_NAME, 1, 54), '_${SHORT_SHA}\` ', + ACTION_TIMING, ' ', EVENT_MANIPULATION, ' ON \`', + '${NEW_PREFIX}', SUBSTRING(EVENT_OBJECT_TABLE, CHAR_LENGTH('${CURRENT_PREFIX}') + 1), '\` ', + 'FOR EACH ROW ', ACTION_STATEMENT, '\$\$' + ) + FROM information_schema.TRIGGERS + WHERE TRIGGER_SCHEMA = DATABASE() + AND LEFT(EVENT_OBJECT_TABLE, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}'" \ + --path="$WP_ROOT" --skip-column-names) + + printf 'DELIMITER $$\n%s\nDELIMITER ;\n' "$TRIGGER_DDL" | wp db query --path="$WP_ROOT" + fi + + log "Applying migrations against new prefix '$NEW_PREFIX'" + WP_ROOT="$WP_ROOT" \ + MIGRATIONS_TABLE="$NEW_MIGRATIONS_TABLE" \ + NEW_PREFIX="$NEW_PREFIX" \ + bash "$MIGRATE_SCRIPT" + + log "Updating usermeta keys and option names from '$CURRENT_PREFIX' to '$NEW_PREFIX'" + wp db query "UPDATE \`${NEW_PREFIX}usermeta\` SET meta_key = REPLACE(meta_key, '${CURRENT_PREFIX}', '${NEW_PREFIX}') WHERE LEFT(meta_key, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}'" --path="$WP_ROOT" + wp db query "UPDATE \`${NEW_PREFIX}options\` SET option_name = REPLACE(option_name, '${CURRENT_PREFIX}', '${NEW_PREFIX}') WHERE LEFT(option_name, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}'" --path="$WP_ROOT" + + # ------------------------------------------------------------------ + # Point of no return — wp-config.php and components are about to change. + # Any failure from here requires manual verification before the site + # can safely come back up. The cleanup trap exits 2 if MAINTENANCE_ACTIVE + # is true and SAFE_TO_RECOVER is false. + # ------------------------------------------------------------------ + SAFE_TO_RECOVER=false + + log "Switching wp-config.php table_prefix to '$NEW_PREFIX'" + wp config set table_prefix "$NEW_PREFIX" --path="$WP_ROOT" + + log "Dropping old tables with prefix '$CURRENT_PREFIX'" + { + echo "SET FOREIGN_KEY_CHECKS=0;" + while IFS= read -r TABLE; do + [ -z "$TABLE" ] && continue + echo "DROP TABLE IF EXISTS \`$TABLE\`;" + done <<< "$TABLES" + echo "SET FOREIGN_KEY_CHECKS=1;" + } | wp db query --path="$WP_ROOT" + + log "Database migrations complete" +fi + +# ============================================================================== +# Step 4: Component swap +# +# Each component is rsynced to a hidden staging directory, then atomically +# renamed into place. WordPress ignores directories starting with '.', so +# the staging copy is never served during the transfer. +# ============================================================================== + +log "Deploying components for release $GIT_SHA" + +mkdir -p "$RELEASES_DIR" + +for COMPONENT in "${COMPONENTS[@]}"; do + TYPE="${COMPONENT%%:*}" + NAME="${COMPONENT##*:}" + LIVE_PATH="$WP_ROOT/wp-content/$TYPE/$NAME" + RELEASE_PATH="$NEW_RELEASE_DIR/$NAME" + STAGING_PATH="${LIVE_PATH}.deploying" + OLD_PATH="${LIVE_PATH}.previous" + + # Clear any remnants from a previous failed deploy + rm -rf "$STAGING_PATH" "$OLD_PATH" + + # Rsync to a hidden staging directory not yet visible to WordPress + mkdir -p "$STAGING_PATH" + rsync -a --delete "$RELEASE_PATH/" "$STAGING_PATH/" + + # Atomic rename: live → .previous, staging → live + if [ -e "$LIVE_PATH" ] || [ -L "$LIVE_PATH" ]; then + mv "$LIVE_PATH" "$OLD_PATH" + fi + mv "$STAGING_PATH" "$LIVE_PATH" + rm -rf "$OLD_PATH" + + log " $TYPE/$NAME -> $RELEASE_PATH" +done + +# ============================================================================== +# Step 5: Disable maintenance mode +# +# Done before pruning so the site comes back up even if cleanup fails. +# MAINTENANCE_ACTIVE is set to false regardless — the cleanup trap must not +# attempt a second deactivation after this point. +# ============================================================================== + +if [ "$MAINTENANCE_ACTIVE" = true ]; then + log "Disabling maintenance mode" + if ! wp maintenance-mode deactivate --path="$WP_ROOT"; then + log "ERROR: Failed to deactivate maintenance mode — run manually:" + log "ERROR: wp maintenance-mode deactivate --path=\"$WP_ROOT\"" + exit 2 + fi + MAINTENANCE_ACTIVE=false +fi + +# ============================================================================== +# Step 6: Prune old releases — keep current + 1 prior +# ============================================================================== + +log "Pruning old releases" + +while IFS= read -r OLD_RELEASE; do + log " Removing $OLD_RELEASE" + rm -rf "$OLD_RELEASE" || log "WARN: Could not remove $OLD_RELEASE — manual cleanup may be needed" +done < <(find "$RELEASES_DIR" -maxdepth 1 -mindepth 1 -type d \ + ! -name "$GIT_SHA" ! -name "initial" \ + -printf '%T@ %p\n' | sort -rn | tail -n +2 | cut -d' ' -f2-) + +# ============================================================================== +# Step 7: Prune old database backups — keep only this deploy's backup +# ============================================================================== + +if [ "$HAS_MIGRATIONS" = true ]; then + log "Pruning old database backups — keeping only $(basename "$BACKUP_FILE")" + + while IFS= read -r OLD_BACKUP; do + log " Removing $OLD_BACKUP" + rm -f "$OLD_BACKUP" || log "WARN: Could not remove $OLD_BACKUP — manual cleanup may be needed" + done < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'pre_deploy_*.sql' ! -name "$(basename "$BACKUP_FILE")") +fi + +log "Atomic deploy complete — $GIT_SHA is live" diff --git a/templates/migrations/queries/.gitkeep b/templates/migrations/queries/.gitkeep new file mode 100644 index 0000000..e69de29