From dbcc5aa904c7d7c71e505492f4e83855c40c5e14 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 09:19:56 +0100 Subject: [PATCH 01/42] updated readme --- README.md | 145 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index b71dd4a..6d5a97f 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,43 @@ # 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 ### 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 +52,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 +121,52 @@ 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 | +| `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`) | From d22a2065bf102721fd37918f6453b65df0eb3aae Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 09:59:23 +0100 Subject: [PATCH 02/42] added new atomic deploy workflow --- .github/workflows/atomic-deploy.yaml | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/atomic-deploy.yaml diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml new file mode 100644 index 0000000..810cc5c --- /dev/null +++ b/.github/workflows/atomic-deploy.yaml @@ -0,0 +1,51 @@ +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 + swap-script: + type: string + description: "Absolute path to the project's swap.sh on the remote server" + required: true + wp-root: + type: string + description: "Absolute path to the WordPress root on the remote server" + required: true + secrets: + SSH_PRIVATE_KEY: + description: "SSH private key" + required: true + +jobs: + atomic_deploy: + runs-on: ubuntu-latest + steps: + + - uses: pie/.github/actions/add-ssh-config@main + 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@main + name: Run atomic swap and migrations + with: + swap-script: ${{inputs.swap-script}} + wp-root: ${{inputs.wp-root}} + git-sha: ${{github.sha}} From f24980882b72dae0c139b142a649e4dd4c4a90fc Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:08:40 +0100 Subject: [PATCH 03/42] added migrate flow --- .github/workflows/migrate.yaml | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/migrate.yaml diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml new file mode 100644 index 0000000..adbbb0b --- /dev/null +++ b/.github/workflows/migrate.yaml @@ -0,0 +1,52 @@ +name: Run Migrations + +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 + migrate-script: + type: string + description: "Absolute path to the project's migrate.sh on the remote server" + required: true + wp-root: + type: string + description: "Absolute path to the WordPress root on the remote server" + required: true + secrets: + SSH_PRIVATE_KEY: + description: "SSH private key" + required: true + +jobs: + run_migrations: + runs-on: ubuntu-latest + steps: + + - uses: pie/.github/actions/add-ssh-config@main + 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/run-migrations@main + name: Run database migrations + with: + migrate-script: ${{inputs.migrate-script}} + wp-root: ${{inputs.wp-root}} + repo-name: ${{github.event.repository.name}} + git-sha: ${{github.sha}} From 305f2331be1f4126eb617963d87e60c3b2882ddd Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:11:30 +0100 Subject: [PATCH 04/42] add swap and migrate action --- .github/workflows/atomic-deploy.yaml | 1 + actions/swap-and-migrate/action.yml | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 actions/swap-and-migrate/action.yml diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml index 810cc5c..cb42be2 100644 --- a/.github/workflows/atomic-deploy.yaml +++ b/.github/workflows/atomic-deploy.yaml @@ -49,3 +49,4 @@ jobs: swap-script: ${{inputs.swap-script}} wp-root: ${{inputs.wp-root}} git-sha: ${{github.sha}} + repo-name: ${{github.event.repository.name}} diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml new file mode 100644 index 0000000..62b5299 --- /dev/null +++ b/actions/swap-and-migrate/action.yml @@ -0,0 +1,28 @@ +name: Swap and Migrate +description: "Performs maintenance mode, database migrations, and symlink swap atomically in a single SSH session" + +inputs: + swap-script: + description: "Absolute path to the project's swap.sh on the remote server" + required: true + wp-root: + description: "Absolute path to the WordPress root on the remote server" + 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 + +runs: + using: composite + steps: + - name: Run atomic swap and migrations + shell: bash + run: | + ssh server "env \ + WP_ROOT='${{ inputs.wp-root }}' \ + GIT_SHA='${{ inputs.git-sha }}' \ + REPO_NAME='${{ inputs.repo-name }}' \ + bash '${{ inputs.swap-script }}'" From 09ea1d08c53f2517bd056f7f9334aeddf1bb0313 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:19:21 +0100 Subject: [PATCH 05/42] added swap script --- templates/migrations/swap.sh | 206 +++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 templates/migrations/swap.sh diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh new file mode 100644 index 0000000..a485735 --- /dev/null +++ b/templates/migrations/swap.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# swap.sh — Atomic deploy: migrations + symlink swap +# +# Called remotely by the swap-and-migrate GitHub Action. Runs in a single SSH +# session. set -euo pipefail means any failure exits immediately — if that +# happens after maintenance mode is activated, the site stays down rather than +# coming back up in a broken state. +# +# Injected by the action: +# WP_ROOT Absolute path to the WordPress root (e.g. ~/site/public_html) +# GIT_SHA Full git commit SHA for this deployment +# REPO_NAME GitHub repository name (used to derive migrations table name) +# +# Copy this file into your project's migrations/ directory and update the +# COMPONENTS array below. Do not edit below the configuration section. +# ============================================================================== + +# ============================================================================== +# PROJECT CONFIGURATION — edit this section only +# ============================================================================== + +# Format: "type:directory-name" — type must be "plugins" or "themes" +COMPONENTS=( + "plugins:my-plugin" + "themes:my-theme" +) + +# ============================================================================== +# Generic infrastructure — do not edit below this line +# ============================================================================== + +SHORT_SHA="${GIT_SHA:0:8}" +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" +MIGRATIONS_TABLE="$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' | cut -c1-53)_migrations" +HAS_MIGRATIONS=false + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# Uses || true so a failed deactivate call never masks the real error +maintenance_off() { + log "Disabling maintenance mode" + wp maintenance-mode deactivate --path="$WP_ROOT" || true +} + +# ============================================================================== +# Step 1: Pre-flight checks +# ============================================================================== + +log "Atomic deploy starting — SHA: $GIT_SHA" + +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" + +if [ ! -d "$NEW_RELEASE_DIR" ]; then + echo "ERROR: Release directory $NEW_RELEASE_DIR not found — did all rsync jobs complete?" >&2 + exit 1 +fi + +# ============================================================================== +# Step 2: Detect pending migrations +# ============================================================================== + +PENDING_FILES=() + +if [ -d "$QUERIES_DIR" ]; then + APPLIED=$(wp db query \ + "SELECT filename FROM \`$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 -qF "$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 +# +# Maintenance mode is activated here. If anything fails from this point on, +# set -euo pipefail exits the script and maintenance mode stays ON — the site +# remains down rather than returning in a broken state. +# ============================================================================== + +if [ "$HAS_MIGRATIONS" = true ]; then + + log "Enabling maintenance mode" + wp maintenance-mode activate --path="$WP_ROOT" + + BACKUP_DIR="$(dirname "$WP_ROOT")/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" + + CURRENT_PREFIX=$(wp config get table_prefix --path="$WP_ROOT") + NEW_PREFIX="wp_${SHORT_SHA}_" + 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 table_name LIKE '${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="$MIGRATIONS_TABLE" \ + CURRENT_PREFIX="$CURRENT_PREFIX" \ + NEW_PREFIX="$NEW_PREFIX" \ + bash "$MIGRATE_SCRIPT" + + 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'" + while IFS= read -r TABLE; do + [ -z "$TABLE" ] && continue + wp db query "DROP TABLE IF EXISTS \`$TABLE\`" --path="$WP_ROOT" + done <<< "$TABLES" + + log "Database migrations complete" +fi + +# ============================================================================== +# Step 4: Symlink swap +# +# On first run, if a real directory exists where a symlink is expected, it is +# moved into releases/initial/ and the symlink is created in its place — no +# manual server setup required. +# ============================================================================== + +log "Swapping symlinks to release $GIT_SHA" + +mkdir -p "$RELEASES_DIR" + +for COMPONENT in "${COMPONENTS[@]}"; do + TYPE="${COMPONENT%%:*}" + NAME="${COMPONENT##*:}" + LINK_PATH="$WP_ROOT/wp-content/$TYPE/$NAME" + 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 + + if [ -d "$LINK_PATH" ] && [ ! -L "$LINK_PATH" ]; then + log " First run: migrating $NAME to releases/initial/" + mkdir -p "$RELEASES_DIR/initial" + mv "$LINK_PATH" "$RELEASES_DIR/initial/" + fi + + ln -sfn "$RELEASE_PATH" "$LINK_PATH" + log " $TYPE/$NAME -> $RELEASE_PATH" +done + +# ============================================================================== +# Step 5: 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" +done < <(find "$RELEASES_DIR" -maxdepth 1 -mindepth 1 -type d \ + ! -name "$GIT_SHA" ! -name "initial" \ + -printf '%T@ %p\n' | sort -rn | awk 'NR>1 {print $2}') + +# ============================================================================== +# Step 6: Disable maintenance mode +# ============================================================================== + +if [ "$HAS_MIGRATIONS" = true ]; then + maintenance_off +fi + +log "Atomic deploy complete — $GIT_SHA is live" From cd492f3ad7b471bbe5c950b526c106e8d66fbed9 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:25:37 +0100 Subject: [PATCH 06/42] added notification settings on error --- .github/workflows/atomic-deploy.yaml | 16 +++++++++++++ actions/swap-and-migrate/action.yml | 36 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml index cb42be2..dd33527 100644 --- a/.github/workflows/atomic-deploy.yaml +++ b/.github/workflows/atomic-deploy.yaml @@ -29,6 +29,18 @@ on: 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 jobs: atomic_deploy: @@ -50,3 +62,7 @@ jobs: wp-root: ${{inputs.wp-root}} git-sha: ${{github.sha}} repo-name: ${{github.event.repository.name}} + 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/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 62b5299..62e8662 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -14,6 +14,22 @@ inputs: repo-name: description: "Repository name used to derive the migrations tracking table name" required: true + 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 @@ -26,3 +42,23 @@ runs: GIT_SHA='${{ inputs.git-sha }}' \ REPO_NAME='${{ inputs.repo-name }}' \ bash '${{ inputs.swap-script }}'" + + - name: Send failure notification + if: failure() && inputs.smtp-server != '' + 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: "Atomic deploy failed — ${{ github.repository }}" + html_body: | +

Atomic deploy failed

+

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

+

View workflow run

+

⚠️ Maintenance mode may still be active.
+ Verify the server manually before restoring traffic.

From f0fd3847c5ec2f905adb81c531ef5ee960334eac Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:30:27 +0100 Subject: [PATCH 07/42] added migration action --- actions/run-migrations/action.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 actions/run-migrations/action.yml diff --git a/actions/run-migrations/action.yml b/actions/run-migrations/action.yml new file mode 100644 index 0000000..edd61f5 --- /dev/null +++ b/actions/run-migrations/action.yml @@ -0,0 +1,28 @@ +name: Run Migrations +description: "Executes pending database migrations on the remote server via SSH" + +inputs: + migrate-script: + description: "Absolute path to the project's migrate.sh on the remote server" + required: true + wp-root: + description: "Absolute path to the WordPress root on the remote server" + required: true + repo-name: + description: "Repository name used to derive the migrations tracking table name" + required: true + git-sha: + description: "Git commit SHA for the current deployment" + required: true + +runs: + using: composite + steps: + - name: Run database migrations + shell: bash + run: | + ssh server "env \ + WP_ROOT='${{ inputs.wp-root }}' \ + GIT_SHA='${{ inputs.git-sha }}' \ + REPO_NAME='${{ inputs.repo-name }}' \ + bash '${{ inputs.migrate-script }}'" From 970a91049d97b07dd63a9dedb431f26ff00688fc Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:37:22 +0100 Subject: [PATCH 08/42] migrate script added --- .github/workflows/migrate.yaml | 52 -------------------- actions/run-migrations/action.yml | 28 ----------- templates/migrations/migrate.sh | 81 +++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 80 deletions(-) delete mode 100644 .github/workflows/migrate.yaml delete mode 100644 actions/run-migrations/action.yml create mode 100644 templates/migrations/migrate.sh diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml deleted file mode 100644 index adbbb0b..0000000 --- a/.github/workflows/migrate.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Run Migrations - -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 - migrate-script: - type: string - description: "Absolute path to the project's migrate.sh on the remote server" - required: true - wp-root: - type: string - description: "Absolute path to the WordPress root on the remote server" - required: true - secrets: - SSH_PRIVATE_KEY: - description: "SSH private key" - required: true - -jobs: - run_migrations: - runs-on: ubuntu-latest - steps: - - - uses: pie/.github/actions/add-ssh-config@main - 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/run-migrations@main - name: Run database migrations - with: - migrate-script: ${{inputs.migrate-script}} - wp-root: ${{inputs.wp-root}} - repo-name: ${{github.event.repository.name}} - git-sha: ${{github.sha}} diff --git a/actions/run-migrations/action.yml b/actions/run-migrations/action.yml deleted file mode 100644 index edd61f5..0000000 --- a/actions/run-migrations/action.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Run Migrations -description: "Executes pending database migrations on the remote server via SSH" - -inputs: - migrate-script: - description: "Absolute path to the project's migrate.sh on the remote server" - required: true - wp-root: - description: "Absolute path to the WordPress root on the remote server" - required: true - repo-name: - description: "Repository name used to derive the migrations tracking table name" - required: true - git-sha: - description: "Git commit SHA for the current deployment" - required: true - -runs: - using: composite - steps: - - name: Run database migrations - shell: bash - run: | - ssh server "env \ - WP_ROOT='${{ inputs.wp-root }}' \ - GIT_SHA='${{ inputs.git-sha }}' \ - REPO_NAME='${{ inputs.repo-name }}' \ - bash '${{ inputs.migrate-script }}'" diff --git a/templates/migrations/migrate.sh b/templates/migrations/migrate.sh new file mode 100644 index 0000000..385a0eb --- /dev/null +++ b/templates/migrations/migrate.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# migrate.sh — Database migration runner +# +# 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. +# +# Injected by swap.sh: +# WP_ROOT Absolute path to the WordPress root +# MIGRATIONS_TABLE Tracking table name (pre-computed by swap.sh) +# CURRENT_PREFIX Current WP table prefix (e.g. wp_) +# 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 -qF "$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/${CURRENT_PREFIX}/${NEW_PREFIX}/g" "$SQL_FILE" \ + | wp db query --path="$WP_ROOT" + + wp db query \ + "INSERT INTO \`$MIGRATIONS_TABLE\` (filename) VALUES ('$FILENAME')" \ + --path="$WP_ROOT" + + log " Applied: $FILENAME" +done + +log "All migrations applied" From 4edf6e16e77b85648afd52ad06687adc003f1655 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:40:58 +0100 Subject: [PATCH 09/42] updated readme --- README.md | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/README.md b/README.md index 6d5a97f..dbba1cc 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,123 @@ This repository contains reusable workflows and composite actions for managing r ## Workflows +### Atomic Deploy + +Deploys components to a release directory keyed by the git commit SHA, then atomically swaps symlinks and runs any pending database migrations in a single maintenance window. Supports instant rollback by re-pointing symlinks to the prior release. + +**How it works:** + +Rsync jobs deploy each component to `~/site/releases/{sha}/` on the server. Once all jobs complete, the `atomic_deploy` job SSH's in and runs the project's `migrations/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 `wp_{short-sha}_` prefix → runs migrations against the copy → switches `wp-config.php` to the new prefix → drops old tables +4. Re-points symlinks to the new release (bootstrapping to the release structure automatically on first run) +5. Prunes releases older than 1 prior + +If anything fails after maintenance mode is activated, the script exits and maintenance mode stays on — the site remains down rather than returning in a broken state. A failure notification is sent to `#uptime_alerts`. + +**Server directory structure:** + +``` +~/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 → symlink → ../../releases/{sha}/my-plugin/ + └── themes/ + └── my-theme → symlink → ../../releases/{sha}/my-theme/ +``` + +**Inputs:** + +- `ssh-host`: SSH host. Required. +- `swap-script`: Absolute path to `migrations/swap.sh` on the server. Required. +- `wp-root`: Absolute path to the WordPress root on the server. 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: + deploy_plugin: + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + destination-path: ~/site/releases/${{ github.sha }}/my-plugin + npm: true + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + deploy_theme: + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + destination-path: ~/site/releases/${{ github.sha }}/my-theme + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + deploy_migrations: + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + destination-path: ~/site/releases/${{ github.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 + swap-script: ~/site/releases/${{ github.sha }}/migrations/swap.sh + wp-root: ~/site/public_html + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + SMTP_SERVER: ${{secrets.SMTP_SERVER}} + SMTP_USERNAME: ${{secrets.SMTP_USERNAME}} + SMTP_PASSWORD: ${{secrets.SMTP_PASSWORD}} +``` + +**Rollback:** + +Re-point each symlink to the prior release on the server, then flush the cache: + +```bash +WP_ROOT=~/site/public_html +PRIOR=$(ls -dt ~/site/releases/*/ | sed -n '2p') + +ln -sfn ${PRIOR}my-plugin $WP_ROOT/wp-content/plugins/my-plugin +ln -sfn ${PRIOR}my-theme $WP_ROOT/wp-content/themes/my-theme + +wp cache flush --path="$WP_ROOT" +``` + +If the failed deploy included database migrations and maintenance mode is still active, check `wp config get table_prefix --path="$WP_ROOT"` before restoring traffic — the prefix switch may not have completed. + +--- + ### Deploy via Rsync Deploys to a remote server using rsync, supporting both SSH key and password-based authentication. @@ -167,6 +284,31 @@ These composite actions are used internally by the workflows above but can also | `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 symlink swap atomically 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's `migrations/` directory and make the scripts executable (`chmod +x migrations/*.sh`). + +| File | What to do | +|---|---| +| `swap.sh` | Edit the `COMPONENTS` array at the top — list each plugin/theme as `"type:directory-name"` | +| `migrate.sh` | Copy as-is, no changes needed | +| `queries/.gitkeep` | Add `.sql` files here; the `.gitkeep` can be removed once real migrations exist | + +**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 +``` + +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. From 4d653b3b00b3ab9f660955a2c786ba86c3c55eaa Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:51:38 +0100 Subject: [PATCH 10/42] only leave maintenance mode active if live actions fail --- actions/swap-and-migrate/action.yml | 55 ++++++++++++++++--- templates/migrations/swap.sh | 85 ++++++++++++++++++++++------- 2 files changed, 113 insertions(+), 27 deletions(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 62e8662..11bc758 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -35,16 +35,25 @@ runs: using: composite steps: - name: Run atomic swap and migrations + id: swap + continue-on-error: true shell: bash run: | + set +e ssh server "env \ WP_ROOT='${{ inputs.wp-root }}' \ GIT_SHA='${{ inputs.git-sha }}' \ REPO_NAME='${{ inputs.repo-name }}' \ bash '${{ inputs.swap-script }}'" + SSH_EXIT=$? + set -e + echo "ssh_exit=${SSH_EXIT}" >> "$GITHUB_OUTPUT" + exit $SSH_EXIT - - name: Send failure notification - if: failure() && inputs.smtp-server != '' + # 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 != '2' && inputs.smtp-server != '' uses: dawidd6/action-send-mail@v3 with: server_address: ${{ inputs.smtp-server }} @@ -54,11 +63,43 @@ runs: password: ${{ inputs.smtp-password }} to: ${{ inputs.notify-email }} from: GitHub Actions - subject: "Atomic deploy failed — ${{ github.repository }}" + subject: "Deploy failed, site recovered — ${{ github.repository }}" html_body: | -

Atomic deploy failed

+

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 != '' + 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="$WP_ROOT"
  2. +
  3. Check symlinks in wp-content point to a consistent release:
    ls -la $WP_ROOT/wp-content/plugins/ $WP_ROOT/wp-content/themes/
  4. +
+

Once the state is confirmed safe, deactivate maintenance mode:
wp maintenance-mode deactivate --path="$WP_ROOT"

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

-

View workflow run

-

⚠️ Maintenance mode may still be active.
- Verify the server manually before restoring traffic.

+

View workflow run for full log

+ + - name: Fail the job + if: steps.swap.outcome == 'failure' + shell: bash + run: exit 1 diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index a485735..895cdaf 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -5,9 +5,13 @@ set -euo pipefail # swap.sh — Atomic deploy: migrations + symlink swap # # Called remotely by the swap-and-migrate GitHub Action. Runs in a single SSH -# session. set -euo pipefail means any failure exits immediately — if that -# happens after maintenance mode is activated, the site stays down rather than -# coming back up in a broken state. +# session. set -euo pipefail means any failure exits immediately. +# +# Exit codes: +# 0 — success +# 1 — failed before any live changes; maintenance mode was deactivated +# 2 — failed after live changes began; site is in maintenance mode — +# manual intervention required before deactivating # # Injected by the action: # WP_ROOT Absolute path to the WordPress root (e.g. ~/site/public_html) @@ -40,14 +44,37 @@ MIGRATE_SCRIPT="$SCRIPT_DIR/migrate.sh" QUERIES_DIR="$SCRIPT_DIR/queries" MIGRATIONS_TABLE="$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' | cut -c1-53)_migrations" HAS_MIGRATIONS=false +MAINTENANCE_ACTIVE=false +SAFE_TO_RECOVER=true log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } -# Uses || true so a failed deactivate call never masks the real error -maintenance_off() { - log "Disabling maintenance mode" - wp maintenance-mode deactivate --path="$WP_ROOT" || true +# 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 symlinks, 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 @@ -97,15 +124,16 @@ fi # ============================================================================== # Step 3: Database migrations # -# Maintenance mode is activated here. If anything fails from this point on, -# set -euo pipefail exits the script and maintenance mode stays ON — the site -# remains down rather than returning in a broken state. +# Maintenance mode is activated here. Any failure at this point is still safe +# to recover from — we haven't touched wp-config.php or symlinks yet. +# The cleanup trap will deactivate maintenance mode and exit 1. # ============================================================================== if [ "$HAS_MIGRATIONS" = true ]; then log "Enabling maintenance mode" wp maintenance-mode activate --path="$WP_ROOT" + MAINTENANCE_ACTIVE=true BACKUP_DIR="$(dirname "$WP_ROOT")/db-backups" mkdir -p "$BACKUP_DIR" @@ -137,6 +165,14 @@ if [ "$HAS_MIGRATIONS" = true ]; then NEW_PREFIX="$NEW_PREFIX" \ bash "$MIGRATE_SCRIPT" + # ------------------------------------------------------------------ + # Point of no return — wp-config.php and symlinks 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" @@ -183,24 +219,33 @@ for COMPONENT in "${COMPONENTS[@]}"; do done # ============================================================================== -# Step 5: Prune old releases — keep current + 1 prior +# 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" + 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 | awk 'NR>1 {print $2}') -# ============================================================================== -# Step 6: Disable maintenance mode -# ============================================================================== - -if [ "$HAS_MIGRATIONS" = true ]; then - maintenance_off -fi - log "Atomic deploy complete — $GIT_SHA is live" From 5fcce966689b3128969d682d6189264cc26ea52c Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 10:52:34 +0100 Subject: [PATCH 11/42] updated readme --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dbba1cc..1974555 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,10 @@ Rsync jobs deploy each component to `~/site/releases/{sha}/` on the server. Once 4. Re-points symlinks to the new release (bootstrapping to the release structure automatically on first run) 5. Prunes releases older than 1 prior -If anything fails after maintenance mode is activated, the script exits and maintenance mode stays on — the site remains down rather than returning in a broken state. A failure notification is sent to `#uptime_alerts`. +Failures are handled based on how far the deploy got: + +- **Before `wp-config.php` or symlinks 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:** @@ -117,7 +120,7 @@ ln -sfn ${PRIOR}my-theme $WP_ROOT/wp-content/themes/my-theme wp cache flush --path="$WP_ROOT" ``` -If the failed deploy included database migrations and maintenance mode is still active, check `wp config get table_prefix --path="$WP_ROOT"` before restoring traffic — the prefix switch may not have completed. +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 symlinks are in a consistent state — the notification email includes the exact commands to run. --- From 1443086aa7c73c4929da8d9b95519df968b00269 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:35:31 +0100 Subject: [PATCH 12/42] fix wildcard call potentially targeting incorrect tables --- templates/migrations/swap.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index 895cdaf..7252d44 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -147,7 +147,8 @@ if [ "$HAS_MIGRATIONS" = true ]; then TABLES=$(wp db query \ "SELECT table_name FROM information_schema.tables \ - WHERE table_schema = DATABASE() AND table_name LIKE '${CURRENT_PREFIX}%'" \ + 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 From ca1e0f6090dea830b63dff3fd2e11d24596fa25d Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:37:47 +0100 Subject: [PATCH 13/42] validate paths early to avoid potential desync on symlinks --- templates/migrations/swap.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index 7252d44..ee7cbe1 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -95,6 +95,16 @@ if [ ! -d "$NEW_RELEASE_DIR" ]; then 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 # ============================================================================== @@ -204,11 +214,6 @@ for COMPONENT in "${COMPONENTS[@]}"; do LINK_PATH="$WP_ROOT/wp-content/$TYPE/$NAME" 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 - if [ -d "$LINK_PATH" ] && [ ! -L "$LINK_PATH" ]; then log " First run: migrating $NAME to releases/initial/" mkdir -p "$RELEASES_DIR/initial" From 0d96206d4a05af8ea27adff7c0c12a82f3ba1ae7 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:39:19 +0100 Subject: [PATCH 14/42] protect SQL by escaping filenames --- templates/migrations/migrate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/migrations/migrate.sh b/templates/migrations/migrate.sh index 385a0eb..35cbfba 100644 --- a/templates/migrations/migrate.sh +++ b/templates/migrations/migrate.sh @@ -71,8 +71,9 @@ for SQL_FILE in "${PENDING[@]}"; do sed "s/${CURRENT_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 ('$FILENAME')" \ + "INSERT INTO \`$MIGRATIONS_TABLE\` (filename) VALUES ('$SAFE_FILENAME')" \ --path="$WP_ROOT" log " Applied: $FILENAME" From f4fc816b8c9a41116e7adfad933c2e30bb3c70c5 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:42:24 +0100 Subject: [PATCH 15/42] escape vars before running remote actions --- actions/swap-and-migrate/action.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 11bc758..691b757 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -38,13 +38,18 @@ runs: id: swap continue-on-error: true shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + GIT_SHA: ${{ inputs.git-sha }} + REPO_NAME: ${{ inputs.repo-name }} + SWAP_SCRIPT: ${{ inputs.swap-script }} run: | set +e - ssh server "env \ - WP_ROOT='${{ inputs.wp-root }}' \ - GIT_SHA='${{ inputs.git-sha }}' \ - REPO_NAME='${{ inputs.repo-name }}' \ - bash '${{ inputs.swap-script }}'" + WP_ROOT_Q=$(printf '%q' "$WP_ROOT") + GIT_SHA_Q=$(printf '%q' "$GIT_SHA") + REPO_NAME_Q=$(printf '%q' "$REPO_NAME") + 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 bash $SWAP_SCRIPT_Q" SSH_EXIT=$? set -e echo "ssh_exit=${SSH_EXIT}" >> "$GITHUB_OUTPUT" From 416d5cd6499b8d2b4026b69afeb016e86a358b4d Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:46:21 +0100 Subject: [PATCH 16/42] more specific error checking for notifications --- actions/swap-and-migrate/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 691b757..8bf4f2e 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -58,7 +58,7 @@ runs: # 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 != '2' && inputs.smtp-server != '' + if: steps.swap.outcome == 'failure' && steps.swap.outputs.ssh_exit == '1' && inputs.smtp-server != '' && inputs.notify-email != '' uses: dawidd6/action-send-mail@v3 with: server_address: ${{ inputs.smtp-server }} @@ -80,7 +80,7 @@ runs: # 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 != '' + if: steps.swap.outcome == 'failure' && steps.swap.outputs.ssh_exit == '2' && inputs.smtp-server != '' && inputs.notify-email != '' uses: dawidd6/action-send-mail@v3 with: server_address: ${{ inputs.smtp-server }} From 121c453e07f17d2f486464a70b16c53c2f336814 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:47:23 +0100 Subject: [PATCH 17/42] update commands in notification email --- actions/swap-and-migrate/action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 8bf4f2e..d898758 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -96,10 +96,10 @@ runs:

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="$WP_ROOT"
  2. -
  3. Check symlinks in wp-content point to a consistent release:
    ls -la $WP_ROOT/wp-content/plugins/ $WP_ROOT/wp-content/themes/
  4. +
  5. Check the active table prefix:
    wp config get table_prefix --path="${{ inputs.wp-root }}"
  6. +
  7. Check symlinks in wp-content point to a consistent release:
    ls -la ${{ inputs.wp-root }}/wp-content/plugins/ ${{ inputs.wp-root }}/wp-content/themes/
-

Once the state is confirmed safe, deactivate maintenance mode:
wp maintenance-mode deactivate --path="$WP_ROOT"

+

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

From ec385e28394c5c273118c2621b80e5cff379b6e5 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:48:24 +0100 Subject: [PATCH 18/42] updated readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1974555..eb81b5d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Github Workflows for PIE.co.de +# GitHub Workflows for PIE.co.de This repository contains reusable workflows and composite actions for managing repository deployment. @@ -6,7 +6,7 @@ This repository contains reusable workflows and composite actions for managing r ### Atomic Deploy -Deploys components to a release directory keyed by the git commit SHA, then atomically swaps symlinks and runs any pending database migrations in a single maintenance window. Supports instant rollback by re-pointing symlinks to the prior release. +Deploys components to a release directory keyed by the git commit SHA, then swaps symlinks and runs any pending database migrations. When migrations are pending, the database work and symlink swap are performed inside a maintenance window. When there are no pending migrations, symlinks are swapped with no downtime. Supports instant rollback by re-pointing symlinks to the prior release. **How it works:** From 032fbce392b88ffb3a7462699823f12d7dbb5595 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:57:12 +0100 Subject: [PATCH 19/42] update action to use absolute paths --- README.md | 18 +++++++++--------- actions/swap-and-migrate/action.yml | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index eb81b5d..5c88308 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ Failures are handled based on how far the deploy got: **Inputs:** - `ssh-host`: SSH host. Required. -- `swap-script`: Absolute path to `migrations/swap.sh` on the server. Required. -- `wp-root`: Absolute path to the WordPress root on the server. Required. +- `swap-script`: Absolute path to `migrations/swap.sh` on the server. Required. Must not start with `~`. +- `wp-root`: Absolute path to the WordPress root on the server. Required. Must not start with `~`. - `ssh-port`: SSH port. Optional, default is `22`. - `ssh-user`: SSH user. Optional, default is `piecode`. @@ -71,7 +71,7 @@ jobs: uses: pie/.github/.github/workflows/deploy.yaml@main with: ssh-host: example.com - destination-path: ~/site/releases/${{ github.sha }}/my-plugin + destination-path: /home/piecode/site/releases/${{ github.sha }}/my-plugin npm: true secrets: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} @@ -80,7 +80,7 @@ jobs: uses: pie/.github/.github/workflows/deploy.yaml@main with: ssh-host: example.com - destination-path: ~/site/releases/${{ github.sha }}/my-theme + destination-path: /home/piecode/site/releases/${{ github.sha }}/my-theme secrets: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} @@ -88,7 +88,7 @@ jobs: uses: pie/.github/.github/workflows/deploy.yaml@main with: ssh-host: example.com - destination-path: ~/site/releases/${{ github.sha }}/migrations + destination-path: /home/piecode/site/releases/${{ github.sha }}/migrations secrets: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} @@ -97,8 +97,8 @@ jobs: uses: pie/.github/.github/workflows/atomic-deploy.yaml@main with: ssh-host: example.com - swap-script: ~/site/releases/${{ github.sha }}/migrations/swap.sh - wp-root: ~/site/public_html + swap-script: /home/piecode/site/releases/${{ github.sha }}/migrations/swap.sh + wp-root: /home/piecode/site/public_html secrets: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} SMTP_SERVER: ${{secrets.SMTP_SERVER}} @@ -111,8 +111,8 @@ jobs: Re-point each symlink to the prior release on the server, then flush the cache: ```bash -WP_ROOT=~/site/public_html -PRIOR=$(ls -dt ~/site/releases/*/ | sed -n '2p') +WP_ROOT=/home/piecode/site/public_html +PRIOR=$(ls -dt /home/piecode/site/releases/*/ | sed -n '2p') ln -sfn ${PRIOR}my-plugin $WP_ROOT/wp-content/plugins/my-plugin ln -sfn ${PRIOR}my-theme $WP_ROOT/wp-content/themes/my-theme diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index d898758..e58ef63 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -34,6 +34,21 @@ inputs: runs: using: composite steps: + - name: Validate inputs + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + SWAP_SCRIPT: ${{ inputs.swap-script }} + run: | + if [[ "$WP_ROOT" == '~'* ]]; then + echo "Error: wp-root must be an absolute path. Replace ~ with the full home directory path (e.g. /home/piecode/site/public_html)." >&2 + exit 1 + fi + if [[ "$SWAP_SCRIPT" == '~'* ]]; then + echo "Error: swap-script must be an absolute path. Replace ~ with the full home directory path (e.g. /home/piecode/site/releases/\$SHA/migrations/swap.sh)." >&2 + exit 1 + fi + - name: Run atomic swap and migrations id: swap continue-on-error: true From f9b59014bd35c85dd310aad4b2e35cf8b8d0507a Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:59:05 +0100 Subject: [PATCH 20/42] make workflows queue not race --- .github/workflows/atomic-deploy.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml index dd33527..1f6dbf8 100644 --- a/.github/workflows/atomic-deploy.yaml +++ b/.github/workflows/atomic-deploy.yaml @@ -42,6 +42,10 @@ on: 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 From 23cf4f007683d8b04013b816707d19689f1dbaee Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 11:59:37 +0100 Subject: [PATCH 21/42] updated comment --- templates/migrations/swap.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index ee7cbe1..0adbdc8 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -14,7 +14,7 @@ set -euo pipefail # manual intervention required before deactivating # # Injected by the action: -# WP_ROOT Absolute path to the WordPress root (e.g. ~/site/public_html) +# WP_ROOT Absolute path to the WordPress root (e.g. /home/piecode/site/public_html) # GIT_SHA Full git commit SHA for this deployment # REPO_NAME GitHub repository name (used to derive migrations table name) # From 728a80471ec295e876f63085c4d3282023bd3120 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 12:00:28 +0100 Subject: [PATCH 22/42] safeguard swap script --- templates/migrations/swap.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index 0adbdc8..6666af7 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -82,6 +82,11 @@ trap cleanup EXIT log "Atomic deploy starting — SHA: $GIT_SHA" +if [[ "$WP_ROOT" == '~'* ]]; then + echo "ERROR: WP_ROOT must be an absolute path. Replace ~ with the full home directory path (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 From 763c1e9ebe5330ac8bedf4a2dbb0fee74ea46269 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 12:29:05 +0100 Subject: [PATCH 23/42] update readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5c88308..8a1a359 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Failures are handled based on how far the deploy got: **Server directory structure:** ``` -~/site/ +/home/piecode/site/ ├── releases/ │ ├── {current-sha}/ ← new deploy lands here via rsync │ │ ├── my-plugin/ @@ -37,9 +37,9 @@ Failures are handled based on how far the deploy got: └── public_html/ ← WordPress root └── wp-content/ ├── plugins/ - │ └── my-plugin → symlink → ../../releases/{sha}/my-plugin/ + │ └── my-plugin → /home/piecode/site/releases/{sha}/my-plugin/ └── themes/ - └── my-theme → symlink → ../../releases/{sha}/my-theme/ + └── my-theme → /home/piecode/site/releases/{sha}/my-theme/ ``` **Inputs:** From 9e17cd842c89849fc03d8cc8d690151fe2d5bad2 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 14:52:39 +0100 Subject: [PATCH 24/42] added logic to prevent additive prefixes --- templates/migrations/swap.sh | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index 6666af7..d6d7e87 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -146,6 +146,32 @@ fi 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_ + CURRENT_PREFIX=$(wp config get table_prefix --path="$WP_ROOT") + BASE_PREFIX=$(printf '%s' "$CURRENT_PREFIX" | sed 's/[0-9a-f]\{8\}_$//') + NEW_PREFIX="${BASE_PREFIX}${SHORT_SHA}_" + 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 @@ -156,8 +182,6 @@ if [ "$HAS_MIGRATIONS" = true ]; then log "Exporting database backup to $BACKUP_FILE" wp db export "$BACKUP_FILE" --path="$WP_ROOT" - CURRENT_PREFIX=$(wp config get table_prefix --path="$WP_ROOT") - NEW_PREFIX="wp_${SHORT_SHA}_" log "Copying tables from prefix '$CURRENT_PREFIX' to '$NEW_PREFIX'" TABLES=$(wp db query \ From 0ab9093f15dd658a7578c3be9940eccedd01b0b1 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 14:54:23 +0100 Subject: [PATCH 25/42] avoid whitespace issues with release directories --- templates/migrations/swap.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index d6d7e87..907abd5 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -281,6 +281,6 @@ while IFS= read -r OLD_RELEASE; do 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 | awk 'NR>1 {print $2}') + -printf '%T@ %p\n' | sort -rn | tail -n +2 | cut -d' ' -f2-) log "Atomic deploy complete — $GIT_SHA is live" From 3c7e4795f26f7800d7ad8a95732947aa9abf272f Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 14:56:24 +0100 Subject: [PATCH 26/42] updated comments --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a1a359..0dd8b5a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This repository contains reusable workflows and composite actions for managing r ### Atomic Deploy -Deploys components to a release directory keyed by the git commit SHA, then swaps symlinks and runs any pending database migrations. When migrations are pending, the database work and symlink swap are performed inside a maintenance window. When there are no pending migrations, symlinks are swapped with no downtime. Supports instant rollback by re-pointing symlinks to the prior release. +Deploys components to a release directory keyed by the git commit SHA, then swaps symlinks and runs any pending database migrations. When migrations are pending, the database work and symlink swap are performed inside a maintenance window. When there are no pending migrations, symlinks are swapped with no downtime. Supports rollback by re-pointing symlinks to the prior release; when migrations ran, the old prefix tables are dropped during deployment, so a full database rollback requires restoring from the pre-deploy backup rather than re-pointing symlinks alone. **How it works:** @@ -108,7 +108,7 @@ jobs: **Rollback:** -Re-point each symlink to the prior release on the server, then flush the cache: +If no migrations ran, re-pointing symlinks to the prior release is sufficient: ```bash WP_ROOT=/home/piecode/site/public_html @@ -120,6 +120,8 @@ ln -sfn ${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 — a symlink-only rollback leaves the old code 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 symlinks are in a consistent state — the notification email includes the exact commands to run. --- From b2c96e393f9a0060a650b0a6ec419821ae907a6e Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 14:57:20 +0100 Subject: [PATCH 27/42] add robust check for absolute paths --- actions/swap-and-migrate/action.yml | 8 ++++---- templates/migrations/swap.sh | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index e58ef63..2d224a8 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -40,12 +40,12 @@ runs: WP_ROOT: ${{ inputs.wp-root }} SWAP_SCRIPT: ${{ inputs.swap-script }} run: | - if [[ "$WP_ROOT" == '~'* ]]; then - echo "Error: wp-root must be an absolute path. Replace ~ with the full home directory path (e.g. /home/piecode/site/public_html)." >&2 + 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 [[ "$SWAP_SCRIPT" == '~'* ]]; then - echo "Error: swap-script must be an absolute path. Replace ~ with the full home directory path (e.g. /home/piecode/site/releases/\$SHA/migrations/swap.sh)." >&2 + if [[ "$SWAP_SCRIPT" != '/'* ]]; then + echo "Error: swap-script must be an absolute path starting with / (e.g. /home/piecode/site/releases/\$SHA/migrations/swap.sh)." >&2 exit 1 fi diff --git a/templates/migrations/swap.sh b/templates/migrations/swap.sh index 907abd5..f4577e0 100644 --- a/templates/migrations/swap.sh +++ b/templates/migrations/swap.sh @@ -82,8 +82,8 @@ trap cleanup EXIT log "Atomic deploy starting — SHA: $GIT_SHA" -if [[ "$WP_ROOT" == '~'* ]]; then - echo "ERROR: WP_ROOT must be an absolute path. Replace ~ with the full home directory path (e.g. /home/piecode/site/public_html)." >&2 +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 From 7bba305cfd193dd998997b7ba8d2e64003649d2f Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 15:18:01 +0100 Subject: [PATCH 28/42] centralise migrate scripts --- .github/workflows/atomic-deploy.yaml | 10 ++-- README.md | 33 ++++++++---- actions/swap-and-migrate/action.yml | 30 +++++++---- .../swap-and-migrate/scripts}/migrate.sh | 15 ++++-- .../swap-and-migrate/scripts}/swap.sh | 53 ++++++++----------- templates/migrations/queries/.gitkeep | 0 6 files changed, 81 insertions(+), 60 deletions(-) rename {templates/migrations => actions/swap-and-migrate/scripts}/migrate.sh (80%) rename {templates/migrations => actions/swap-and-migrate/scripts}/swap.sh (86%) create mode 100644 templates/migrations/queries/.gitkeep diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml index 1f6dbf8..cd37bbe 100644 --- a/.github/workflows/atomic-deploy.yaml +++ b/.github/workflows/atomic-deploy.yaml @@ -17,13 +17,13 @@ on: description: "SSH port to connect to" required: false default: 22 - swap-script: + wp-root: type: string - description: "Absolute path to the project's swap.sh on the remote server" + description: "Absolute path to the WordPress root on the remote server (must start with /)" required: true - wp-root: + components: type: string - description: "Absolute path to the WordPress root on the remote server" + description: "Newline-separated list of components to deploy, one per line in type:name format (e.g. plugins:my-plugin)" required: true secrets: SSH_PRIVATE_KEY: @@ -62,10 +62,10 @@ jobs: - uses: pie/.github/actions/swap-and-migrate@main name: Run atomic swap and migrations with: - swap-script: ${{inputs.swap-script}} wp-root: ${{inputs.wp-root}} git-sha: ${{github.sha}} repo-name: ${{github.event.repository.name}} + components: ${{inputs.components}} notify-email: ${{secrets.NOTIFY_EMAIL || 'uptime_alerts-aaaaabagrbjrarifmw25mquney@piecode.slack.com'}} smtp-server: ${{secrets.SMTP_SERVER}} smtp-username: ${{secrets.SMTP_USERNAME}} diff --git a/README.md b/README.md index 0dd8b5a..9efff28 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ Failures are handled based on how far the deploy got: **Inputs:** - `ssh-host`: SSH host. Required. -- `swap-script`: Absolute path to `migrations/swap.sh` on the server. Required. Must not start with `~`. -- `wp-root`: Absolute path to the WordPress root on the server. Required. Must not start with `~`. +- `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`. @@ -97,8 +97,10 @@ jobs: uses: pie/.github/.github/workflows/atomic-deploy.yaml@main with: ssh-host: example.com - swap-script: /home/piecode/site/releases/${{ github.sha }}/migrations/swap.sh 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}} @@ -114,8 +116,8 @@ If no migrations ran, re-pointing symlinks to the prior release is sufficient: WP_ROOT=/home/piecode/site/public_html PRIOR=$(ls -dt /home/piecode/site/releases/*/ | sed -n '2p') -ln -sfn ${PRIOR}my-plugin $WP_ROOT/wp-content/plugins/my-plugin -ln -sfn ${PRIOR}my-theme $WP_ROOT/wp-content/themes/my-theme +ln -sfn "${PRIOR}my-plugin" "$WP_ROOT/wp-content/plugins/my-plugin" +ln -sfn "${PRIOR}my-theme" "$WP_ROOT/wp-content/themes/my-theme" wp cache flush --path="$WP_ROOT" ``` @@ -300,13 +302,15 @@ These composite actions are used internally by the workflows above but can also ### SQL Migrations -Copy `templates/migrations/` into your project's `migrations/` directory and make the scripts executable (`chmod +x migrations/*.sh`). +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. -| File | What to do | -|---|---| -| `swap.sh` | Edit the `COMPONENTS` array at the top — list each plugin/theme as `"type:directory-name"` | -| `migrate.sh` | Copy as-is, no changes needed | -| `queries/.gitkeep` | Add `.sql` files here; the `.gitkeep` can be removed once real migrations exist | +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. @@ -316,4 +320,11 @@ migrations/queries/ └── 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/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 2d224a8..ad51787 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -2,11 +2,8 @@ name: Swap and Migrate description: "Performs maintenance mode, database migrations, and symlink swap atomically in a single SSH session" inputs: - swap-script: - description: "Absolute path to the project's swap.sh on the remote server" - required: true wp-root: - description: "Absolute path to the WordPress root on the remote server" + 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" @@ -14,6 +11,9 @@ inputs: 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 notify-email: description: "Email address to notify on failure" required: false @@ -38,16 +38,26 @@ runs: shell: bash env: WP_ROOT: ${{ inputs.wp-root }} - SWAP_SCRIPT: ${{ inputs.swap-script }} 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 - if [[ "$SWAP_SCRIPT" != '/'* ]]; then - echo "Error: swap-script must be an absolute path starting with / (e.g. /home/piecode/site/releases/\$SHA/migrations/swap.sh)." >&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 }} + run: | + MIGRATIONS_DIR="$(dirname "$WP_ROOT")/releases/$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" + ssh server "chmod +x $MIGRATIONS_DIR_Q/swap.sh $MIGRATIONS_DIR_Q/migrate.sh" + printf '%s' "$DEPLOY_COMPONENTS" | ssh server "cat > $MIGRATIONS_DIR_Q/components.txt" - name: Run atomic swap and migrations id: swap @@ -57,12 +67,12 @@ runs: WP_ROOT: ${{ inputs.wp-root }} GIT_SHA: ${{ inputs.git-sha }} REPO_NAME: ${{ inputs.repo-name }} - SWAP_SCRIPT: ${{ inputs.swap-script }} run: | set +e WP_ROOT_Q=$(printf '%q' "$WP_ROOT") GIT_SHA_Q=$(printf '%q' "$GIT_SHA") REPO_NAME_Q=$(printf '%q' "$REPO_NAME") + SWAP_SCRIPT="$(dirname "$WP_ROOT")/releases/$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 bash $SWAP_SCRIPT_Q" SSH_EXIT=$? diff --git a/templates/migrations/migrate.sh b/actions/swap-and-migrate/scripts/migrate.sh similarity index 80% rename from templates/migrations/migrate.sh rename to actions/swap-and-migrate/scripts/migrate.sh index 35cbfba..f3e665d 100644 --- a/templates/migrations/migrate.sh +++ b/actions/swap-and-migrate/scripts/migrate.sh @@ -4,15 +4,24 @@ 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) -# CURRENT_PREFIX Current WP table prefix (e.g. wp_) -# NEW_PREFIX New prefix to target (e.g. wp_a1b2c3d4_) +# NEW_PREFIX New prefix to target (e.g. wp_a1b2c3d4_) # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -68,7 +77,7 @@ for SQL_FILE in "${PENDING[@]}"; do FILENAME=$(basename "$SQL_FILE") log "Applying $FILENAME" - sed "s/${CURRENT_PREFIX}/${NEW_PREFIX}/g" "$SQL_FILE" \ + sed "s/__WP_PREFIX__/${NEW_PREFIX}/g" "$SQL_FILE" \ | wp db query --path="$WP_ROOT" SAFE_FILENAME=$(printf '%s' "$FILENAME" | sed "s/'/''/g") diff --git a/templates/migrations/swap.sh b/actions/swap-and-migrate/scripts/swap.sh similarity index 86% rename from templates/migrations/swap.sh rename to actions/swap-and-migrate/scripts/swap.sh index f4577e0..cede9ed 100644 --- a/templates/migrations/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -4,36 +4,16 @@ set -euo pipefail # ============================================================================== # swap.sh — Atomic deploy: migrations + symlink swap # -# Called remotely by the swap-and-migrate GitHub Action. Runs in a single SSH -# session. set -euo pipefail means any failure exits immediately. -# -# Exit codes: -# 0 — success -# 1 — failed before any live changes; maintenance mode was deactivated -# 2 — failed after live changes began; site is in maintenance mode — -# manual intervention required before deactivating +# 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 Full git commit SHA for this deployment -# REPO_NAME GitHub repository name (used to derive migrations table name) +# WP_ROOT Absolute path to the WordPress root (e.g. /home/piecode/site/public_html) +# GIT_SHA Full git commit SHA for this deployment +# REPO_NAME GitHub repository name (used to derive migrations table name) # -# Copy this file into your project's migrations/ directory and update the -# COMPONENTS array below. Do not edit below the configuration section. -# ============================================================================== - -# ============================================================================== -# PROJECT CONFIGURATION — edit this section only -# ============================================================================== - -# Format: "type:directory-name" — type must be "plugins" or "themes" -COMPONENTS=( - "plugins:my-plugin" - "themes:my-theme" -) - -# ============================================================================== -# Generic infrastructure — do not edit below this line +# 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}" @@ -100,6 +80,19 @@ if [ ! -d "$NEW_RELEASE_DIR" ]; then 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##*:}" @@ -139,9 +132,8 @@ fi # ============================================================================== # Step 3: Database migrations # -# Maintenance mode is activated here. Any failure at this point is still safe -# to recover from — we haven't touched wp-config.php or symlinks yet. -# The cleanup trap will deactivate maintenance mode and exit 1. +# 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 @@ -201,7 +193,6 @@ if [ "$HAS_MIGRATIONS" = true ]; then log "Applying migrations against new prefix '$NEW_PREFIX'" WP_ROOT="$WP_ROOT" \ MIGRATIONS_TABLE="$MIGRATIONS_TABLE" \ - CURRENT_PREFIX="$CURRENT_PREFIX" \ NEW_PREFIX="$NEW_PREFIX" \ bash "$MIGRATE_SCRIPT" diff --git a/templates/migrations/queries/.gitkeep b/templates/migrations/queries/.gitkeep new file mode 100644 index 0000000..e69de29 From bebf68f1254a38827afab0c018b04460a8d387b8 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 15:19:13 +0100 Subject: [PATCH 29/42] more robust checks --- actions/swap-and-migrate/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index ad51787..141c506 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -83,7 +83,7 @@ runs: # 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.notify-email != '' + 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 }} From f9c1741f2cc021ff642c12706939eb12773253a8 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 15:27:27 +0100 Subject: [PATCH 30/42] minor fixes --- actions/swap-and-migrate/action.yml | 2 +- actions/swap-and-migrate/scripts/migrate.sh | 2 +- actions/swap-and-migrate/scripts/swap.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 141c506..ecd4f0b 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -105,7 +105,7 @@ runs: # 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.notify-email != '' + 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 }} diff --git a/actions/swap-and-migrate/scripts/migrate.sh b/actions/swap-and-migrate/scripts/migrate.sh index f3e665d..2b07f43 100644 --- a/actions/swap-and-migrate/scripts/migrate.sh +++ b/actions/swap-and-migrate/scripts/migrate.sh @@ -57,7 +57,7 @@ APPLIED=$(wp db query \ PENDING=() while IFS= read -r SQL_FILE; do FILENAME=$(basename "$SQL_FILE") - if ! echo "$APPLIED" | grep -qF "$FILENAME"; then + if ! echo "$APPLIED" | grep -qxF "$FILENAME"; then PENDING+=("$SQL_FILE") fi done < <(find "$QUERIES_DIR" -maxdepth 1 -name "*.sql" | sort) diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index cede9ed..65eb2c9 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -116,7 +116,7 @@ if [ -d "$QUERIES_DIR" ]; then while IFS= read -r SQL_FILE; do FILENAME=$(basename "$SQL_FILE") - if ! echo "$APPLIED" | grep -qF "$FILENAME"; then + if ! echo "$APPLIED" | grep -qxF "$FILENAME"; then PENDING_FILES+=("$SQL_FILE") fi done < <(find "$QUERIES_DIR" -maxdepth 1 -name "*.sql" | sort) From befc17e2ed715fa72447287c68b95c72cabefbb0 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 15:35:00 +0100 Subject: [PATCH 31/42] updated branch refs for testing --- .github/workflows/atomic-deploy.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml index cd37bbe..24f7d0c 100644 --- a/.github/workflows/atomic-deploy.yaml +++ b/.github/workflows/atomic-deploy.yaml @@ -51,7 +51,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 runner with: ssh-user: ${{inputs.ssh-user}} @@ -59,7 +59,7 @@ jobs: ssh-host: ${{inputs.ssh-host}} ssh-key: ${{secrets.SSH_PRIVATE_KEY}} - - uses: pie/.github/actions/swap-and-migrate@main + - 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}} From 355a8afaf75bbed774725bd17d99ff9018ba076d Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 15:59:29 +0100 Subject: [PATCH 32/42] mkdir if not exists and update branch refs --- .github/workflows/deploy.yaml | 6 +++--- actions/deploy-via-rsync/action.yml | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) 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/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}} From 505cec04bafc7c3cc93039606dbab3a451469208 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 16:22:58 +0100 Subject: [PATCH 33/42] updated passable releases dir --- .github/workflows/atomic-deploy.yaml | 6 ++++++ actions/swap-and-migrate/action.yml | 23 ++++++++++++++++++++--- actions/swap-and-migrate/scripts/swap.sh | 4 ++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml index 24f7d0c..5136945 100644 --- a/.github/workflows/atomic-deploy.yaml +++ b/.github/workflows/atomic-deploy.yaml @@ -25,6 +25,11 @@ on: 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" @@ -66,6 +71,7 @@ jobs: git-sha: ${{github.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}} diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index ecd4f0b..786db86 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -14,6 +14,10 @@ inputs: 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 @@ -50,8 +54,14 @@ runs: WP_ROOT: ${{ inputs.wp-root }} GIT_SHA: ${{ inputs.git-sha }} DEPLOY_COMPONENTS: ${{ inputs.components }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} run: | - MIGRATIONS_DIR="$(dirname "$WP_ROOT")/releases/$GIT_SHA/migrations" + 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" @@ -67,14 +77,21 @@ runs: 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") - SWAP_SCRIPT="$(dirname "$WP_ROOT")/releases/$GIT_SHA/migrations/swap.sh" + 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 bash $SWAP_SCRIPT_Q" + 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" diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index 65eb2c9..236197c 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -17,7 +17,7 @@ set -euo pipefail # ============================================================================== SHORT_SHA="${GIT_SHA:0:8}" -RELEASES_DIR="$(dirname "$WP_ROOT")/releases" +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" @@ -168,7 +168,7 @@ if [ "$HAS_MIGRATIONS" = true ]; then wp maintenance-mode activate --path="$WP_ROOT" MAINTENANCE_ACTIVE=true - BACKUP_DIR="$(dirname "$WP_ROOT")/db-backups" + 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" From 3fd5dae0e7679284b90346cd0646015d9edbe1ec Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 16:26:14 +0100 Subject: [PATCH 34/42] remove redundant chmos --- actions/swap-and-migrate/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index 786db86..abe0bfc 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -66,7 +66,6 @@ runs: 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" - ssh server "chmod +x $MIGRATIONS_DIR_Q/swap.sh $MIGRATIONS_DIR_Q/migrate.sh" printf '%s' "$DEPLOY_COMPONENTS" | ssh server "cat > $MIGRATIONS_DIR_Q/components.txt" - name: Run atomic swap and migrations From 92ef559d320e4aac73ecbda464749447dc3a06d3 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 16:32:44 +0100 Subject: [PATCH 35/42] use short sha --- .github/workflows/atomic-deploy.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml index 5136945..451935b 100644 --- a/.github/workflows/atomic-deploy.yaml +++ b/.github/workflows/atomic-deploy.yaml @@ -56,6 +56,11 @@ jobs: 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: @@ -68,7 +73,7 @@ jobs: name: Run atomic swap and migrations with: wp-root: ${{inputs.wp-root}} - git-sha: ${{github.sha}} + git-sha: ${{ steps.sha.outputs.short_sha }} repo-name: ${{github.event.repository.name}} components: ${{inputs.components}} releases-dir: ${{inputs.releases-dir}} From 17be1307a76361b6a62b249471a57501bbfcd049 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 16:47:59 +0100 Subject: [PATCH 36/42] fix prefix custom table --- actions/swap-and-migrate/scripts/swap.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index 236197c..15aa031 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -22,7 +22,7 @@ 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" -MIGRATIONS_TABLE="$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' | cut -c1-53)_migrations" +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 @@ -75,6 +75,9 @@ 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 @@ -111,7 +114,7 @@ PENDING_FILES=() if [ -d "$QUERIES_DIR" ]; then APPLIED=$(wp db query \ - "SELECT filename FROM \`$MIGRATIONS_TABLE\`" \ + "SELECT filename FROM \`$LIVE_MIGRATIONS_TABLE\`" \ --path="$WP_ROOT" --skip-column-names 2>/dev/null || echo "") while IFS= read -r SQL_FILE; do @@ -141,9 +144,9 @@ 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_ - CURRENT_PREFIX=$(wp config get table_prefix --path="$WP_ROOT") 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 \ @@ -192,7 +195,7 @@ if [ "$HAS_MIGRATIONS" = true ]; then log "Applying migrations against new prefix '$NEW_PREFIX'" WP_ROOT="$WP_ROOT" \ - MIGRATIONS_TABLE="$MIGRATIONS_TABLE" \ + MIGRATIONS_TABLE="$NEW_MIGRATIONS_TABLE" \ NEW_PREFIX="$NEW_PREFIX" \ bash "$MIGRATE_SCRIPT" From d3da835adda12ccff60a4ad3127e78f894a37138 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 16:57:57 +0100 Subject: [PATCH 37/42] update drop command --- actions/swap-and-migrate/scripts/swap.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index 15aa031..6be3ded 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -211,10 +211,14 @@ if [ "$HAS_MIGRATIONS" = true ]; then wp config set table_prefix "$NEW_PREFIX" --path="$WP_ROOT" log "Dropping old tables with prefix '$CURRENT_PREFIX'" - while IFS= read -r TABLE; do - [ -z "$TABLE" ] && continue - wp db query "DROP TABLE IF EXISTS \`$TABLE\`" --path="$WP_ROOT" - done <<< "$TABLES" + { + 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 From d03bddf393dfd1473d1b3464bc5c750ba0d71da7 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Tue, 30 Jun 2026 17:04:57 +0100 Subject: [PATCH 38/42] update wp db prefix for user caps --- actions/swap-and-migrate/scripts/swap.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index 6be3ded..7903207 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -199,6 +199,10 @@ if [ "$HAS_MIGRATIONS" = true ]; then 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 symlinks are about to change. # Any failure from here requires manual verification before the site From 54eef11842b344fa3f3938069c7d01c7ae0121fa Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Thu, 2 Jul 2026 10:49:51 +0100 Subject: [PATCH 39/42] changed symlinks to mv --- README.md | 47 ++++++++++++++++-------- actions/swap-and-migrate/scripts/swap.sh | 21 ++++++++--- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9efff28..43925cf 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,21 @@ This repository contains reusable workflows and composite actions for managing r ### Atomic Deploy -Deploys components to a release directory keyed by the git commit SHA, then swaps symlinks and runs any pending database migrations. When migrations are pending, the database work and symlink swap are performed inside a maintenance window. When there are no pending migrations, symlinks are swapped with no downtime. Supports rollback by re-pointing symlinks to the prior release; when migrations ran, the old prefix tables are dropped during deployment, so a full database rollback requires restoring from the pre-deploy backup rather than re-pointing symlinks alone. +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 `~/site/releases/{sha}/` on the server. Once all jobs complete, the `atomic_deploy` job SSH's in and runs the project's `migrations/swap.sh`, which: +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 `wp_{short-sha}_` prefix → runs migrations against the copy → switches `wp-config.php` to the new prefix → drops old tables -4. Re-points symlinks to the new release (bootstrapping to the release structure automatically on first run) +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 symlinks change** — maintenance mode is deactivated automatically and the site recovers on the previous version. A notification is sent with subject *Deploy failed, site recovered*. +- **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:** @@ -37,9 +37,9 @@ Failures are handled based on how far the deploy got: └── public_html/ ← WordPress root └── wp-content/ ├── plugins/ - │ └── my-plugin → /home/piecode/site/releases/{sha}/my-plugin/ + │ └── my-plugin/ ← files copied from releases/{sha}/my-plugin/ └── themes/ - └── my-theme → /home/piecode/site/releases/{sha}/my-theme/ + └── my-theme/ ← files copied from releases/{sha}/my-theme/ ``` **Inputs:** @@ -67,28 +67,41 @@ on: branches: - production 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 + deploy_plugin: + needs: setup uses: pie/.github/.github/workflows/deploy.yaml@main with: ssh-host: example.com - destination-path: /home/piecode/site/releases/${{ github.sha }}/my-plugin + 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/${{ github.sha }}/my-theme + 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 - destination-path: /home/piecode/site/releases/${{ github.sha }}/migrations + source-path: migrations/ + destination-path: /home/piecode/site/releases/${{ needs.setup.outputs.short-sha }}/migrations secrets: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} @@ -98,6 +111,7 @@ jobs: 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 @@ -110,21 +124,22 @@ jobs: **Rollback:** -If no migrations ran, re-pointing symlinks to the prior release is sufficient: +If no migrations ran, resync each component from the prior release back to the live directory: ```bash WP_ROOT=/home/piecode/site/public_html -PRIOR=$(ls -dt /home/piecode/site/releases/*/ | sed -n '2p') +RELEASES=/home/piecode/site/releases +PRIOR=$(ls -dt "$RELEASES"/*/ | sed -n '2p') -ln -sfn "${PRIOR}my-plugin" "$WP_ROOT/wp-content/plugins/my-plugin" -ln -sfn "${PRIOR}my-theme" "$WP_ROOT/wp-content/themes/my-theme" +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 — a symlink-only rollback leaves the old code 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 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 symlinks are in a consistent state — the notification email includes the exact commands to run. +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. --- diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index 7903207..7be0801 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -242,16 +242,25 @@ mkdir -p "$RELEASES_DIR" for COMPONENT in "${COMPONENTS[@]}"; do TYPE="${COMPONENT%%:*}" NAME="${COMPONENT##*:}" - LINK_PATH="$WP_ROOT/wp-content/$TYPE/$NAME" + LIVE_PATH="$WP_ROOT/wp-content/$TYPE/$NAME" RELEASE_PATH="$NEW_RELEASE_DIR/$NAME" + STAGING_PATH="${LIVE_PATH}.deploying" + OLD_PATH="${LIVE_PATH}.previous" - if [ -d "$LINK_PATH" ] && [ ! -L "$LINK_PATH" ]; then - log " First run: migrating $NAME to releases/initial/" - mkdir -p "$RELEASES_DIR/initial" - mv "$LINK_PATH" "$RELEASES_DIR/initial/" + # 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" - ln -sfn "$RELEASE_PATH" "$LINK_PATH" log " $TYPE/$NAME -> $RELEASE_PATH" done From 31f5107452a2ce984a2ad2f76c6bf220cee925bf Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Thu, 2 Jul 2026 11:24:37 +0100 Subject: [PATCH 40/42] update scripts --- README.md | 2 +- actions/swap-and-migrate/action.yml | 4 ++-- actions/swap-and-migrate/scripts/swap.sh | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 43925cf..e9a975f 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ These composite actions are used internally by the workflows above but can also | `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 symlink swap atomically in a single SSH session | +| `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`) | diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml index abe0bfc..7c7a09c 100644 --- a/actions/swap-and-migrate/action.yml +++ b/actions/swap-and-migrate/action.yml @@ -1,5 +1,5 @@ name: Swap and Migrate -description: "Performs maintenance mode, database migrations, and symlink swap atomically in a single SSH session" +description: "Performs maintenance mode, database migrations, and atomic component swap in a single SSH session" inputs: wp-root: @@ -138,7 +138,7 @@ runs:

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 symlinks in wp-content point to a consistent release:
    ls -la ${{ inputs.wp-root }}/wp-content/plugins/ ${{ inputs.wp-root }}/wp-content/themes/
  4. +
  5. 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/

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

Repository: ${{ github.repository }}
diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index 7be0801..c033f4f 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -2,7 +2,7 @@ set -euo pipefail # ============================================================================== -# swap.sh — Atomic deploy: migrations + symlink swap +# 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. @@ -32,7 +32,7 @@ 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 symlinks, it is safe to +# 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. @@ -204,7 +204,7 @@ if [ "$HAS_MIGRATIONS" = true ]; then 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 symlinks are about to change. + # 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. @@ -228,14 +228,14 @@ if [ "$HAS_MIGRATIONS" = true ]; then fi # ============================================================================== -# Step 4: Symlink swap +# Step 4: Component swap # -# On first run, if a real directory exists where a symlink is expected, it is -# moved into releases/initial/ and the symlink is created in its place — no -# manual server setup required. +# 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 "Swapping symlinks to release $GIT_SHA" +log "Deploying components for release $GIT_SHA" mkdir -p "$RELEASES_DIR" From d1d82107135ec3af68f0a58cd4e8922cd7e29d89 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Thu, 2 Jul 2026 11:36:32 +0100 Subject: [PATCH 41/42] updated readme --- .github/workflows/setup.yaml | 18 ++++++++++++++++++ README.md | 8 +------- 2 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/setup.yaml 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 e9a975f..148b9c3 100644 --- a/README.md +++ b/README.md @@ -68,13 +68,7 @@ on: - production 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 + uses: pie/.github/.github/workflows/setup.yaml@main deploy_plugin: needs: setup From 21f8d6ff1e925de9e61cd7debc83742e3771db16 Mon Sep 17 00:00:00 2001 From: Joey Gilham Date: Thu, 2 Jul 2026 12:50:57 +0100 Subject: [PATCH 42/42] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- actions/swap-and-migrate/scripts/swap.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh index c033f4f..4069446 100644 --- a/actions/swap-and-migrate/scripts/swap.sh +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -9,7 +9,7 @@ set -euo pipefail # # Injected by the action: # WP_ROOT Absolute path to the WordPress root (e.g. /home/piecode/site/public_html) -# GIT_SHA Full git commit SHA for this deployment +# 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