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/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..148b9c3 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,176 @@ -# 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 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 commit SHA. 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 → 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 + +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:** + +``` +/home/piecode/site/ +├── releases/ +│ ├── {current-sha}/ ← new deploy lands here via rsync +│ │ ├── my-plugin/ +│ │ ├── my-theme/ +│ │ └── migrations/ +│ └── {previous-sha}/ ← kept for rollback +├── db-backups/ ← pre-migration exports (when migrations run) +└── public_html/ ← WordPress root + └── wp-content/ + ├── plugins/ + │ └── my-plugin/ ← files copied from releases/{sha}/my-plugin/ + └── themes/ + └── my-theme/ ← files copied from releases/{sha}/my-theme/ +``` + +**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. + +**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/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/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/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 + releases-dir: /home/piecode/site/releases + 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/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 +185,65 @@ jobs: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} ``` -### Synchronise Environments +--- -**Usage Notes:** +### Deploy via FTP -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 +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}} +``` + +--- + +### Synchronise Environments + +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 +254,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 ae5d1db..d6fa05b 100644 --- a/actions/deploy-via-rsync/action.yml +++ b/actions/deploy-via-rsync/action.yml @@ -105,6 +105,13 @@ runs: run: | echo EXCLUDE_FROM="" >> "$GITHUB_ENV" + - 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}} @@ -126,6 +133,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..7c7a09c --- /dev/null +++ b/actions/swap-and-migrate/action.yml @@ -0,0 +1,151 @@ +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' sibling of 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 }} + 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 + + - 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="$(dirname "$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="$(dirname "$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 != '' + uses: dawidd6/action-send-mail@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 or symlinks 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 != '' + uses: dawidd6/action-send-mail@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..4069446 --- /dev/null +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -0,0 +1,297 @@ +#!/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:-$(dirname "$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" +REPO_SLUG="$(printf '%s' "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' | cut -c1-53)" +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") +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 + +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 + + # Derive the new prefix from the stable base — strip any previous atomic-deploy + # SHA suffix (8 hex chars + _) so the base never grows across repeated deploys. + # e.g. wp_ -> wp_abc12345_; foo_abc12345_ -> foo_ -> foo_def67890_ + BASE_PREFIX=$(printf '%s' "$CURRENT_PREFIX" | sed 's/[0-9a-f]\{8\}_$//') + 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 + + 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" + + 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 "WARN: Failed to deactivate maintenance mode — run manually:" + log "WARN: wp maintenance-mode deactivate --path=\"$WP_ROOT\"" + 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-) + +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