diff --git a/.github/workflows/atomic-deploy.yaml b/.github/workflows/atomic-deploy.yaml new file mode 100644 index 0000000..756b411 --- /dev/null +++ b/.github/workflows/atomic-deploy.yaml @@ -0,0 +1,67 @@ +name: Atomic Deploy + +on: + workflow_call: + inputs: + ssh-host: + type: string + description: "SSH host to connect to" + required: true + ssh-user: + type: string + description: "SSH user to connect with" + required: false + default: piecode + ssh-port: + type: number + description: "SSH port to connect to" + required: false + default: 22 + wp-root: + type: string + description: "Absolute path to the WordPress root on the remote server (must start with /)" + required: true + components: + type: string + description: "Newline-separated list of components to deploy, one per line in type:name format (e.g. plugins:my-plugin)" + required: true + releases-dir: + type: string + description: "Absolute path to the releases directory on the server. Defaults to a 'releases' sibling of wp-root when not set." + required: false + default: "" + secrets: + SSH_PRIVATE_KEY: + description: "SSH private key" + required: true + +concurrency: + group: atomic-deploy-${{ inputs.ssh-host }}-${{ inputs.wp-root }} + cancel-in-progress: false + +jobs: + atomic_deploy: + runs-on: ubuntu-latest + steps: + + - name: Compute short SHA + id: sha + shell: bash + run: echo "short_sha=${GITHUB_SHA:0:8}" >> $GITHUB_OUTPUT + + - uses: pie/.github/actions/add-ssh-config@feature/add-symlink-and-sql-flows + name: Add SSH key to runner + with: + ssh-user: ${{inputs.ssh-user}} + ssh-port: ${{inputs.ssh-port}} + ssh-host: ${{inputs.ssh-host}} + ssh-key: ${{secrets.SSH_PRIVATE_KEY}} + + - uses: pie/.github/actions/swap-and-migrate@feature/add-symlink-and-sql-flows + name: Run atomic swap and migrations + with: + wp-root: ${{inputs.wp-root}} + git-sha: ${{ steps.sha.outputs.short_sha }} + repo-name: ${{github.event.repository.name}} + components: ${{inputs.components}} + releases-dir: ${{inputs.releases-dir}} diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index dd8ad6b..19e843e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: pie/.github/actions/add-ssh-config@main + - uses: pie/.github/actions/add-ssh-config@feature/add-symlink-and-sql-flows name: Add SSH Key to this runner if: ${{ inputs.sshpass == 'false' }} with: @@ -85,7 +85,7 @@ jobs: ssh-host: ${{inputs.ssh-host}} ssh-key: ${{secrets.SSH_PRIVATE_KEY}} - - uses: pie/.github/actions/add-ssh-pass@main + - uses: pie/.github/actions/add-ssh-pass@feature/add-symlink-and-sql-flows name: Add SSH Pass to this runner if: ${{ inputs.sshpass != 'false' }} with: @@ -94,7 +94,7 @@ jobs: ssh-host: ${{inputs.ssh-host}} ssh-pass: ${{secrets.SSH_PRIVATE_KEY}} - - uses: pie/.github/actions/deploy-via-rsync@main + - uses: pie/.github/actions/deploy-via-rsync@feature/add-symlink-and-sql-flows name: Deploying to ${{inputs.destination-path}} on remote with: ssh-port: ${{inputs.ssh-port}} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fa7f44b..a01f050 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -66,25 +66,14 @@ jobs: git push - name: Check for ignore file - id: check_files - uses: andstor/file-existence-action@v2 - with: - files: ".zipignore" - - - name: File exists - if: steps.check_files.outputs.files_exists == 'true' - shell: bash - # Only runs if all of the files exists - run: | - sed -i '/^[[:space:]]*$/d' .zipignore - sed 's/^/${{ github.event.repository.name }}\//' .zipignore > ../.zipignore - - - name: File does not exist - if: steps.check_files.outputs.files_exists != 'true' shell: bash - # Only runs if any of the files does not exist run: | - touch ../.zipignore + if [ -f ".zipignore" ]; then + sed -i '/^[[:space:]]*$/d' .zipignore + sed 's/^/${{ github.event.repository.name }}\//' .zipignore > ../.zipignore + else + touch ../.zipignore + fi # This step creates a zip of the plugin which can be used for installation, and by the plugin updater. - name: Build required zip artifact diff --git a/.github/workflows/rollback-migrations.yaml b/.github/workflows/rollback-migrations.yaml new file mode 100644 index 0000000..f592ce8 --- /dev/null +++ b/.github/workflows/rollback-migrations.yaml @@ -0,0 +1,61 @@ +name: Rollback 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 + wp-root: + type: string + description: "Absolute path to the WordPress root on the remote server (must start with /)" + required: true + releases-dir: + type: string + description: "Absolute path to a writable directory on the server, used only to stage a temporary working directory for this run. Defaults to a 'releases' subdirectory inside wp-root when not set." + required: false + default: "" + migrations-path: + type: string + description: "Local path (relative to the checked-out repo) containing the migrations directory, i.e. the parent of migrations/queries" + required: false + default: "migrations" + secrets: + SSH_PRIVATE_KEY: + description: "SSH private key" + required: true + +jobs: + rollback_migrations: + runs-on: ubuntu-latest + steps: + + - name: Check out the repo + uses: actions/checkout@v4 + + - uses: pie/.github/actions/add-ssh-config@feature/add-symlink-and-sql-flows + name: Add SSH key to runner + with: + ssh-user: ${{inputs.ssh-user}} + ssh-port: ${{inputs.ssh-port}} + ssh-host: ${{inputs.ssh-host}} + ssh-key: ${{secrets.SSH_PRIVATE_KEY}} + + - uses: pie/.github/actions/rollback-migrations@feature/add-symlink-and-sql-flows + name: Roll back most recent migration batch + with: + wp-root: ${{inputs.wp-root}} + repo-name: ${{github.event.repository.name}} + releases-dir: ${{inputs.releases-dir}} + migrations-path: ${{inputs.migrations-path}} diff --git a/.github/workflows/setup.yaml b/.github/workflows/setup.yaml new file mode 100644 index 0000000..264a2c3 --- /dev/null +++ b/.github/workflows/setup.yaml @@ -0,0 +1,18 @@ +name: Setup + +on: + workflow_call: + outputs: + short-sha: + description: "Short (8-character) git commit SHA for use in release directory paths" + value: ${{ jobs.setup.outputs.short_sha }} + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + short_sha: ${{ steps.sha.outputs.short_sha }} + steps: + - id: sha + shell: bash + run: echo "short_sha=${GITHUB_SHA:0:8}" >> $GITHUB_OUTPUT diff --git a/README.md b/README.md index b71dd4a..77f8598 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,241 @@ -# Github Workflows for PIE.co.de +# GitHub Workflows for PIE.co.de -This repository contains some re-usable workflows and actions for managing repository deployment +This repository contains reusable workflows and composite actions for managing repository deployment. ## Workflows +### Atomic Deploy + +Deploys components to a release directory keyed by the short (8-character) git commit SHA, then atomically swaps them into place and runs any pending database migrations directly against the live tables. 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. Migrations run with no table clone or automated backup — the only pre-flight check is a dry run against a disposable structure-only clone. Take a full site backup and confirm migrations against staging before deploying; see **Migrations run against live tables** below. + +**How it works:** + +Rsync jobs deploy each component to a release directory keyed by the short SHA (see the `setup` workflow's `short-sha` output). Once all jobs complete, the `atomic_deploy` job SSH's in and runs `swap.sh`, which: + +1. Verifies WP-CLI can reach the database +2. Checks for pending SQL migrations +3. If any: enables maintenance mode → dry-runs the pending migrations against structure-only clones of the live tables (no data, dropped immediately after) and bails out with maintenance mode deactivated if any fail → runs migrations directly against the live 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 migrations start** (dry run failed, or none were pending) — maintenance mode is deactivated automatically and the site recovers on the previous version. +- **After migrations start** — maintenance mode stays on; there is no clone or backup to recover from automatically. Manual verification instructions are printed in the run's log output. + +No email notification is sent — GitHub's own workflow-failure notifications (to whoever triggered the run, per their notification settings) cover that; check the Actions log for which case applies and what to do next. + +**Migrations run against live tables:** + +Earlier versions of this workflow cloned every table to a new prefix, migrated the copy, then switched `wp-config.php` over — giving an instant fallback if something went wrong, at the cost of a lot of moving parts (full DB export, foreign key/trigger reconstruction, prefix bookkeeping) for a safety net that MySQL's non-transactional DDL couldn't fully honour anyway. Mainstream migration tools (Laravel, Rails, Django) don't clone either — they migrate live tables directly, for the same reason. This workflow now does the same: + +- **Confirm migrations against a staging copy of the site first.** The dry run here only checks that the SQL is syntactically valid against the live schema — it can't tell you whether the migration does the right thing. +- **Take a full site backup before deploying migrations.** Nothing in this workflow backs up the database. If a migration fails partway through, the affected tables are left in whatever state that migration reached, and the deploy stops with the site in maintenance mode for manual recovery — there's no automatic revert. + +**Server directory structure:** + +`releases/` is created inside `wp-root` — not a sibling of it — because some hosts don't grant the deploy user write access above the web root. See **Requirements** below for the access rule this requires. + +``` +/home/piecode/site/public_html/ ← WordPress root +├── releases/ +│ ├── {current-sha}/ ← new deploy lands here via rsync +│ │ ├── my-plugin/ +│ │ ├── my-theme/ +│ │ └── migrations/ +│ └── {previous-sha}/ ← kept for rollback +└── wp-content/ + ├── plugins/ + │ └── my-plugin/ ← files copied from releases/{sha}/my-plugin/ + └── themes/ + └── my-theme/ ← files copied from releases/{sha}/my-theme/ +``` + +**Requirements:** + +Before running this workflow, block public HTTP access to `releases/` under `wp-root`: + +- **Apache** — create `releases/.htaccess` containing: + ```apache + Require all denied + ``` +- **Nginx** — add to the site's server block: + ```nginx + location ~ ^/releases/ { deny all; } + ``` + +**Inputs:** + +- `ssh-host`: SSH host. Required. +- `wp-root`: Absolute path to the WordPress root on the server. Required. Must start with `/`. +- `components`: Newline-separated list of components in `type:name` format. Required. +- `ssh-port`: SSH port. Optional, default is `22`. +- `ssh-user`: SSH user. Optional, default is `piecode`. + +**Secrets:** + +- `SSH_PRIVATE_KEY`: SSH private key. Required. + +**Example:** + +```yaml +name: Deploy to Production +on: + push: + branches: + - production +jobs: + setup: + uses: pie/.github/.github/workflows/setup.yaml@main + + deploy_plugin: + needs: setup + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + destination-path: /home/piecode/site/public_html/releases/${{ needs.setup.outputs.short-sha }}/my-plugin + npm: true + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + deploy_theme: + needs: setup + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + destination-path: /home/piecode/site/public_html/releases/${{ needs.setup.outputs.short-sha }}/my-theme + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + deploy_migrations: + needs: setup + uses: pie/.github/.github/workflows/deploy.yaml@main + with: + ssh-host: example.com + source-path: migrations/ + destination-path: /home/piecode/site/public_html/releases/${{ needs.setup.outputs.short-sha }}/migrations + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + atomic_deploy: + needs: [deploy_plugin, deploy_theme, deploy_migrations] + uses: pie/.github/.github/workflows/atomic-deploy.yaml@main + with: + ssh-host: example.com + wp-root: /home/piecode/site/public_html + components: | + plugins:my-plugin + themes:my-theme + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} +``` + +**Rollback:** + +If no migrations ran, resync each component from the prior release back to the live directory: + +```bash +WP_ROOT=/home/piecode/site/public_html +RELEASES=/home/piecode/site/public_html/releases +PRIOR=$(ls -dt "$RELEASES"/*/ | sed -n '2p') + +rsync -a --delete "${PRIOR}my-plugin/" "$WP_ROOT/wp-content/plugins/my-plugin/" +rsync -a --delete "${PRIOR}my-theme/" "$WP_ROOT/wp-content/themes/my-theme/" + +wp cache flush --path="$WP_ROOT" +``` + +If migrations ran, rolling back the code alone leaves it running against the migrated schema, which may or may not be compatible. Two options, in order of preference: + +1. **Run the [Rollback Migrations](#rollback-migrations) workflow**, if the migrations that ran define a `-- +migrate Down` section — see **SQL Migrations** below. It only reverses schema shape, not data a migration deleted or transformed. +2. **Restore from your own pre-deploy backup** — required for anything the rollback can't undo (a migration with no Down section, or one that changed data). See **Migrations run against live tables** above — this workflow doesn't take a backup for you. + +If the workflow run's log shows the site is still in maintenance mode, the deploy failed after migrations had already started. Before deactivating maintenance mode, verify which migrations were recorded as applied and that component directories are in a consistent state — the log output includes the exact commands to run. + +**Cleaning up after a manual recovery:** the `releases/{sha}/` directory for a failed deploy (its component copies, `swap.sh`, `migrate.sh`, `queries/`) is only pruned by a *later successful* deploy's own pruning step (Step 5) — a run that stops for manual recovery never reaches it. In practice this self-heals within a deploy or two once you're back to shipping normally, since pruning keeps only the current release plus one prior regardless of which ones succeeded. If you're not deploying again soon and want it gone immediately, it's safe to `rm -rf releases/{sha}/` yourself. + +--- + +### Rollback Migrations + +Reverts the most recently applied batch of database migrations, directly against the live tables. Triggered manually from the Actions tab (`workflow_dispatch`) — no SSH access to the deploy user is needed, since it reuses the same `SSH_PRIVATE_KEY` secret the deploy pipeline already has. + +**How it works:** + +Checks out the repo, connects over SSH (same as Atomic Deploy), uploads a fresh copy of `rollback.sh` plus the local `migrations/queries/` directory to a temporary directory on the server, runs it, then deletes that temporary directory regardless of outcome — nothing is left behind on the server. + +`rollback.sh` derives the same `table_prefix` and migrations tracking table `swap.sh` would have used (from `wp-root` and the repository name — no need to look either up yourself), finds the most recently applied batch (the migrations from one specific deploy, identified by its short SHA), and reverts them **in reverse order**: + +- A migration with a `-- +migrate Down` section: its Down SQL runs, then its tracking row is removed — only once the Down SQL actually succeeds, so a failure partway through a batch leaves an accurate record of what's still applied, and re-running the workflow picks up from there. +- A migration with no Down section: left as-is, logged clearly, not treated as an error — this is deliberate so a mixed batch (some migrations revertible, some not) doesn't fail outright partway through. + +Down only reverses schema shape, not data a migration deleted or transformed — restore from your own backup for that (see **Migrations run against live tables** under Atomic Deploy). + +**Inputs:** + +- `ssh-host`: SSH host. Required. +- `wp-root`: Absolute path to the WordPress root on the server. Required. Must start with `/`. +- `ssh-port`: SSH port. Optional, default is `22`. +- `ssh-user`: SSH user. Optional, default is `piecode`. +- `releases-dir`: Absolute path to a writable directory used only to stage this run's temporary working directory. Optional, defaults to a `releases` subdirectory inside `wp-root`. +- `migrations-path`: Local path (relative to the checked-out repo) containing the `migrations` directory. Optional, default is `migrations`. + +**Secrets:** + +- `SSH_PRIVATE_KEY`: SSH private key. Required — the same one used for Atomic Deploy. + +**Example:** + +```yaml +name: Rollback Migrations +on: + workflow_dispatch: +jobs: + rollback_migrations: + uses: pie/.github/.github/workflows/rollback-migrations.yaml@main + with: + ssh-host: example.com + wp-root: /home/piecode/site/public_html + secrets: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} +``` + +--- + ### 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 +250,65 @@ jobs: SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} ``` -### Synchronise Environments +--- + +### Deploy via FTP + +Deploys to a remote server over FTP with optional Composer and npm build steps. + +**Inputs:** -**Usage Notes:** +- `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`. -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 +**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 +319,99 @@ 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 | +| `rollback-migrations` | Reverts the most recently applied batch of database migrations over SSH | +| `swap-and-migrate` | Runs DB migrations and atomic component swap in a single SSH session | +| `synchronise-remote` | Executes a synchronisation script on a remote server over SSH | +| `verify-branch-is-correct` | Fails the job if the current branch does not match the expected branch (default: `production`) | +| `verify-branch-is-up-to-date` | Fails the job if the current branch is behind the target branch (default: `main`) | + +--- + +## Templates + +### SQL Migrations + +Copy `templates/migrations/` into your project to get the `migrations/queries/` directory structure. No scripts are needed per-project — `swap.sh` and `migrate.sh` are bundled with the action and uploaded to the server automatically on each deploy; `rollback.sh` is bundled separately and only uploaded when the **Rollback Migrations** workflow runs. + +The calling workflow should rsync `migrations/` to `releases/${{ github.sha }}/migrations` and pass the component list to the `atomic-deploy` workflow: + +```yaml +components: | + plugins:my-plugin + themes:my-theme +``` + +**Naming convention:** `{four-digit-number}_{description}.sql` — the number controls execution order. Gaps are fine. Never renumber or delete a migration once committed. + +``` +migrations/queries/ +├── 0001_add_source_column.sql +└── 0002_backfill_source_column.sql +``` + +**Table prefix placeholder:** Use `__WP_PREFIX__` in migration files wherever a table prefix is needed. It is replaced with the correct prefix at deploy time. Never hardcode `wp_` or any other prefix — a global string replacement would risk corrupting string literals or comments that happen to contain the prefix. + +```sql +-- 0001_add_source_column.sql +ALTER TABLE __WP_PREFIX__posts ADD COLUMN source VARCHAR(255) DEFAULT NULL; +``` + +**Rollback (optional):** split a file into `-- +migrate Up` and `-- +migrate Down` sections to make it revertible via the **Rollback Migrations** workflow (see above). A file with no markers — like the plain example above — is treated as Up-only; rollback leaves its change in place and logs that it has nothing to revert, rather than guessing or failing. `Down` should reverse the schema shape `Up` created — it can't recover data `Up` deleted or transformed unless you explicitly write logic to preserve it first. + +```sql +-- 0001_add_source_column.sql + +-- +migrate Up +ALTER TABLE __WP_PREFIX__posts ADD COLUMN source VARCHAR(255) DEFAULT NULL; + +-- +migrate Down +ALTER TABLE __WP_PREFIX__posts DROP COLUMN source; +``` + +Migrations are tracked per-project in a table named `{repo_name}_migrations` (derived automatically), including which deploy (`batch`) applied each one — the **Rollback Migrations** workflow uses this to undo one deploy's migrations at a time, most-recently-applied first. The table is created on first run if it does not exist. diff --git a/actions/deploy-via-rsync/action.yml b/actions/deploy-via-rsync/action.yml index c1cc490..a292ac0 100644 --- a/actions/deploy-via-rsync/action.yml +++ b/actions/deploy-via-rsync/action.yml @@ -92,11 +92,18 @@ runs: shell: bash run: | if [ -f ".rsyncignore" ]; then - echo EXCLUDE_FROM=" --exclude-from=.rsyncignore " >> "$GITHUB_ENV" + echo "EXCLUDE_FROM= --exclude-from=.rsyncignore " >> "$GITHUB_ENV" else - echo EXCLUDE_FROM="" >> "$GITHUB_ENV" + echo "EXCLUDE_FROM=" >> "$GITHUB_ENV" fi + - name: Create destination directory (pubkey) + if: inputs.sshpass != 'true' + shell: bash + env: + DEST: ${{ inputs.destination-path }} + run: ssh server "mkdir -p $(printf '%q' "$DEST")" + - name: Rsync to the remote with pubkey if: inputs.sshpass != 'true' run: rsync ${{inputs.rsync-flags}} ${{inputs.rsync-args}} ${{ env.EXCLUDE_FROM }} ${{inputs.source-path}} server:${{inputs.destination-path}} @@ -118,6 +125,13 @@ runs: fi shell: bash + - name: Create destination directory (sshpass) + if: inputs.sshpass == 'true' + shell: bash + env: + DEST: ${{ inputs.destination-path }} + run: sshpass -e ssh -o StrictHostKeyChecking=no -p ${{inputs.ssh-port}} ${{inputs.ssh-user}}@${{inputs.ssh-host}} "mkdir -p $(printf '%q' "$DEST")" + - name: Rsync to the remote with sshpass if: inputs.sshpass == 'true' run: sshpass -e rsync ${{inputs.rsync-flags}} ${{inputs.rsync-args}} ${{ env.EXCLUDE_FROM }} -e 'ssh -o StrictHostKeyChecking=no -p ${{inputs.ssh-port}}' ${{inputs.source-path}} ${{inputs.ssh-user}}@${{inputs.ssh-host}}:${{inputs.destination-path}} diff --git a/actions/rollback-migrations/action.yml b/actions/rollback-migrations/action.yml new file mode 100644 index 0000000..39ee379 --- /dev/null +++ b/actions/rollback-migrations/action.yml @@ -0,0 +1,81 @@ +name: Rollback Migrations +description: "Reverts the most recently applied batch of database migrations directly against the live tables" + +inputs: + wp-root: + description: "Absolute path to the WordPress root on the remote server (must start with /)" + required: true + repo-name: + description: "Repository name used to derive the migrations tracking table name" + required: true + releases-dir: + description: "Absolute path to a writable directory on the server, used only to stage a temporary working directory for this run — cleaned up afterward regardless of outcome. Defaults to a 'releases' subdirectory inside wp-root when not set." + required: false + default: "" + migrations-path: + description: "Local path (relative to the checked-out repo) containing the migrations directory, i.e. the parent of migrations/queries" + required: false + default: "migrations" + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + run: | + if [[ "$WP_ROOT" != '/'* ]]; then + echo "Error: wp-root must be an absolute path starting with / (e.g. /home/piecode/site/public_html)." >&2 + exit 1 + fi + + if [ -n "$RELEASES_DIR_INPUT" ] && [[ "$RELEASES_DIR_INPUT" != '/'* ]]; then + echo "Error: releases-dir must be an absolute path starting with / (e.g. /home/piecode/site/public_html/releases)." >&2 + exit 1 + fi + + - name: Upload rollback script and migrations + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + MIGRATIONS_PATH: ${{ inputs.migrations-path }} + run: | + if [ -n "$RELEASES_DIR_INPUT" ]; then + RELEASES_DIR="$RELEASES_DIR_INPUT" + else + RELEASES_DIR="$WP_ROOT/releases" + fi + # A dot-prefixed, per-run directory under releases/ — reuses the deny + # rule already required for releases/ rather than needing a new one, + # and is unique enough to never collide with a real deploy's SHA. + # Cleaned up in the final step regardless of how the run goes. + ROLLBACK_DIR="$RELEASES_DIR/.rollback-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "ROLLBACK_DIR=$ROLLBACK_DIR" >> "$GITHUB_ENV" + + ROLLBACK_DIR_Q=$(printf '%q' "$ROLLBACK_DIR") + ssh server "mkdir -p $ROLLBACK_DIR_Q/queries" + scp "$GITHUB_ACTION_PATH/scripts/rollback.sh" "server:$ROLLBACK_DIR/rollback.sh" + if [ -d "$MIGRATIONS_PATH/queries" ]; then + rsync -a "$MIGRATIONS_PATH/queries/" "server:$ROLLBACK_DIR/queries/" + fi + + - name: Run rollback + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + REPO_NAME: ${{ inputs.repo-name }} + run: | + WP_ROOT_Q=$(printf '%q' "$WP_ROOT") + REPO_NAME_Q=$(printf '%q' "$REPO_NAME") + ROLLBACK_SCRIPT_Q=$(printf '%q' "$ROLLBACK_DIR/rollback.sh") + ssh server "env WP_ROOT=$WP_ROOT_Q REPO_NAME=$REPO_NAME_Q bash $ROLLBACK_SCRIPT_Q" + + - name: Clean up remote temporary directory + if: always() + shell: bash + run: | + ROLLBACK_DIR_Q=$(printf '%q' "$ROLLBACK_DIR") + ssh server "rm -rf $ROLLBACK_DIR_Q" || true diff --git a/actions/rollback-migrations/scripts/rollback.sh b/actions/rollback-migrations/scripts/rollback.sh new file mode 100644 index 0000000..c3f33c8 --- /dev/null +++ b/actions/rollback-migrations/scripts/rollback.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# rollback.sh — Reverts the most recently applied batch of database migrations +# +# Uploaded fresh to the server by the rollback-migrations action each time it +# runs — this is not part of a regular deploy, and nothing is left behind +# afterward. +# +# Runs directly against the live tables — same as migrate.sh, there is no +# clone or backup here. Only migrations whose file has a "-- +migrate Down" +# section are reverted, in reverse order of application; anything without one +# is left as-is (its schema change stays in place, logged clearly) rather +# than guessing at an undo or failing the whole run. A migration is only +# marked as rolled back (its tracking row removed) once its Down section has +# actually run successfully, so a failure partway through a batch leaves an +# accurate record of what's still applied — re-running this script picks up +# from there. +# +# Down only reverses schema shape, not data a migration deleted or +# transformed — restore from your own backup for that. +# +# Injected by the action: +# WP_ROOT Absolute path to the WordPress root +# REPO_NAME GitHub repository name — the migrations table name is derived +# from this the same way swap.sh does, so this always finds the +# same tracking table a deploy would have used. +# ============================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +QUERIES_DIR="$SCRIPT_DIR/queries" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# Extracts the "Up" or "Down" section from a migration file. A file with no +# "-- +migrate Up" marker at all is treated as one plain Up-only migration — +# prints the whole file for "up", nothing for "down". +extract_section() { + local file="$1" section="$2" + if [ "$section" = "down" ]; then + if ! grep -q '^-- +migrate Down[[:space:]]*$' "$file"; then + return 0 + fi + awk '/^-- \+migrate Down[[:space:]]*$/{flag=1; next} flag' "$file" + else + if ! grep -q '^-- +migrate Up[[:space:]]*$' "$file"; then + cat "$file" + return 0 + fi + awk '/^-- \+migrate Up[[:space:]]*$/{flag=1; next} /^-- \+migrate Down[[:space:]]*$/{flag=0} flag' "$file" + fi +} + +if [[ "$WP_ROOT" != '/'* ]]; then + echo "ERROR: WP_ROOT must be an absolute path starting with / (e.g. /home/piecode/site/public_html)." >&2 + exit 1 +fi + +if ! command -v wp &>/dev/null; then + echo "ERROR: wp-cli is not available on this server" >&2 + exit 1 +fi + +log "Verifying database connectivity" +wp db check --path="$WP_ROOT" + +TARGET_PREFIX=$(wp config get table_prefix --path="$WP_ROOT") + +# Same validation as swap.sh — TARGET_PREFIX is interpolated into SQL below. +if [[ ! "$TARGET_PREFIX" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "ERROR: table_prefix '$TARGET_PREFIX' contains unexpected characters — refusing to use it in SQL. Expected only letters, digits, and underscores, not starting with a digit." >&2 + exit 1 +fi + +# Same derivation as swap.sh, so this always resolves to the same tracking +# table a deploy would have used. +MIGRATIONS_SUFFIX="_migrations" +MAX_SLUG_LEN=$(( 64 - ${#TARGET_PREFIX} - ${#MIGRATIONS_SUFFIX} )) +if [ "$MAX_SLUG_LEN" -lt 1 ]; then + echo "ERROR: table_prefix '$TARGET_PREFIX' is too long to derive a migrations table name within MySQL's 64-character identifier limit." >&2 + exit 1 +fi +REPO_SLUG="$(printf '%s' "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' | cut -c1-"$MAX_SLUG_LEN")" +MIGRATIONS_TABLE="${TARGET_PREFIX}${REPO_SLUG}_migrations" + +if [ ! -d "$QUERIES_DIR" ]; then + log "No queries directory found — nothing to roll back" + exit 0 +fi + +# The batch column is normally added by migrate.sh, but only as a side +# effect of a deploy that has a pending migration to apply — this script +# can't assume that has already happened by the time it runs, so it ensures +# both the table and the column exist itself, the same way migrate.sh does. +wp db query " + CREATE TABLE IF NOT EXISTS \`$MIGRATIONS_TABLE\` ( + id INT AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + batch VARCHAR(8) NOT NULL DEFAULT '', + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) +" --path="$WP_ROOT" + +wp db query "ALTER TABLE \`$MIGRATIONS_TABLE\` ADD COLUMN IF NOT EXISTS batch VARCHAR(8) NOT NULL DEFAULT '' AFTER filename" --path="$WP_ROOT" + +# COUNT(*) always returns exactly one row, even when it's 0 — wp db query +# falls back to printing a generic status line instead of nothing for a +# SELECT that matches zero rows, which a direct "is this empty" check on a +# LIMIT 1 query could mistake for real output. Gate on the count first. +MIGRATION_COUNT=$(wp db query \ + "SELECT COUNT(*) FROM \`$MIGRATIONS_TABLE\`" \ + --path="$WP_ROOT" --skip-column-names 2>/dev/null || echo "0") + +if [ -z "$MIGRATION_COUNT" ] || [ "$MIGRATION_COUNT" -eq 0 ]; then + log "No applied migrations found — nothing to roll back" + exit 0 +fi + +BATCH=$(wp db query \ + "SELECT batch FROM \`$MIGRATIONS_TABLE\` ORDER BY id DESC LIMIT 1" \ + --path="$WP_ROOT" --skip-column-names) + +log "Rolling back batch '$BATCH'" + +SAFE_BATCH=$(printf '%s' "$BATCH" | sed "s/'/''/g") +FILENAMES=$(wp db query \ + "SELECT filename FROM \`$MIGRATIONS_TABLE\` WHERE batch = '$SAFE_BATCH' ORDER BY id DESC" \ + --path="$WP_ROOT" --skip-column-names) + +while IFS= read -r FILENAME; do + [ -z "$FILENAME" ] && continue + SQL_FILE="$QUERIES_DIR/$FILENAME" + + if [ ! -f "$SQL_FILE" ]; then + log "WARN: $FILENAME is recorded as applied but its file is missing from queries/ — skipping, tracking row left as-is" + continue + fi + + DOWN_SQL=$(extract_section "$SQL_FILE" down) + + if [ -z "$DOWN_SQL" ]; then + log " $FILENAME has no '-- +migrate Down' section — leaving its change in place, skipping" + continue + fi + + log "Reverting $FILENAME" + printf '%s\n' "$DOWN_SQL" | sed "s/__WP_PREFIX__/${TARGET_PREFIX}/g" | wp db query --path="$WP_ROOT" + + SAFE_FILENAME=$(printf '%s' "$FILENAME" | sed "s/'/''/g") + wp db query \ + "DELETE FROM \`$MIGRATIONS_TABLE\` WHERE filename = '$SAFE_FILENAME'" \ + --path="$WP_ROOT" + + log " Reverted: $FILENAME" +done <<< "$FILENAMES" + +log "Rollback of batch '$BATCH' complete" diff --git a/actions/swap-and-migrate/action.yml b/actions/swap-and-migrate/action.yml new file mode 100644 index 0000000..37c2664 --- /dev/null +++ b/actions/swap-and-migrate/action.yml @@ -0,0 +1,96 @@ +name: Swap and Migrate +description: "Performs maintenance mode, database migrations, and atomic component swap in a single SSH session" + +inputs: + wp-root: + description: "Absolute path to the WordPress root on the remote server (must start with /)" + required: true + git-sha: + description: "Git commit SHA for the current deployment" + required: true + repo-name: + description: "Repository name used to derive the migrations tracking table name" + required: true + components: + description: "Newline-separated list of components to deploy, one per line in type:name format (e.g. plugins:my-plugin)" + required: true + releases-dir: + description: "Absolute path to the releases directory on the server. Defaults to a 'releases' subdirectory inside wp-root when not set." + required: false + default: "" + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + run: | + if [[ "$WP_ROOT" != '/'* ]]; then + echo "Error: wp-root must be an absolute path starting with / (e.g. /home/piecode/site/public_html)." >&2 + exit 1 + fi + + # A relative override here ends up relative to whatever directory the + # SSH login session lands in, not wp-root — and swap.sh's pruning step + # runs rm -rf against whatever it finds there, unrelated first-level + # directories included. + if [ -n "$RELEASES_DIR_INPUT" ] && [[ "$RELEASES_DIR_INPUT" != '/'* ]]; then + echo "Error: releases-dir must be an absolute path starting with / (e.g. /home/piecode/site/public_html/releases)." >&2 + exit 1 + fi + + - name: Upload swap scripts to server + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + GIT_SHA: ${{ inputs.git-sha }} + DEPLOY_COMPONENTS: ${{ inputs.components }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + run: | + if [ -n "$RELEASES_DIR_INPUT" ]; then + RELEASES_DIR="$RELEASES_DIR_INPUT" + else + RELEASES_DIR="$WP_ROOT/releases" + fi + MIGRATIONS_DIR="$RELEASES_DIR/$GIT_SHA/migrations" + MIGRATIONS_DIR_Q=$(printf '%q' "$MIGRATIONS_DIR") + ssh server "mkdir -p $MIGRATIONS_DIR_Q" + scp "$GITHUB_ACTION_PATH/scripts/swap.sh" "server:$MIGRATIONS_DIR/swap.sh" + scp "$GITHUB_ACTION_PATH/scripts/migrate.sh" "server:$MIGRATIONS_DIR/migrate.sh" + printf '%s' "$DEPLOY_COMPONENTS" | ssh server "cat > $MIGRATIONS_DIR_Q/components.txt" + + - name: Run atomic swap and migrations + id: swap + continue-on-error: true + shell: bash + env: + WP_ROOT: ${{ inputs.wp-root }} + GIT_SHA: ${{ inputs.git-sha }} + REPO_NAME: ${{ inputs.repo-name }} + RELEASES_DIR_INPUT: ${{ inputs.releases-dir }} + run: | + set +e + WP_ROOT_Q=$(printf '%q' "$WP_ROOT") + GIT_SHA_Q=$(printf '%q' "$GIT_SHA") + REPO_NAME_Q=$(printf '%q' "$REPO_NAME") + if [ -n "$RELEASES_DIR_INPUT" ]; then + RELEASES_DIR="$RELEASES_DIR_INPUT" + else + RELEASES_DIR="$WP_ROOT/releases" + fi + RELEASES_DIR_Q=$(printf '%q' "$RELEASES_DIR") + SWAP_SCRIPT="$RELEASES_DIR/$GIT_SHA/migrations/swap.sh" + SWAP_SCRIPT_Q=$(printf '%q' "$SWAP_SCRIPT") + ssh server "env WP_ROOT=$WP_ROOT_Q GIT_SHA=$GIT_SHA_Q REPO_NAME=$REPO_NAME_Q RELEASES_DIR=$RELEASES_DIR_Q bash $SWAP_SCRIPT_Q" + SSH_EXIT=$? + set -e + echo "ssh_exit=${SSH_EXIT}" >> "$GITHUB_OUTPUT" + exit $SSH_EXIT + + - name: Fail the job + if: steps.swap.outcome == 'failure' + shell: bash + run: exit 1 diff --git a/actions/swap-and-migrate/scripts/migrate.sh b/actions/swap-and-migrate/scripts/migrate.sh new file mode 100644 index 0000000..2f4d609 --- /dev/null +++ b/actions/swap-and-migrate/scripts/migrate.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# migrate.sh — Database migration runner +# +# Uploaded to the server by the swap-and-migrate action on each deploy. +# Do not copy or edit this file per-project — changes belong in the action. +# +# Called as a subprocess from swap.sh during an atomic deploy. Applies pending +# SQL migrations directly against the live tables — there is no clone or +# backup to fall back to if a migration fails partway through. +# +# Migration files must use __WP_PREFIX__ as a placeholder for the table prefix. +# This token is replaced with TARGET_PREFIX before execution, ensuring only +# explicit prefix references are rewritten — never string literals or comments +# that happen to contain the prefix substring. +# +# A migration file may optionally split its SQL into "-- +migrate Up" and +# "-- +migrate Down" sections — only the Up section runs here (Down is used +# by rollback.sh, uploaded alongside this file but never run automatically). +# A file with no markers at all is treated as Up-only, for migrations written +# before this convention existed. +# +# Example: +# -- +migrate Up +# ALTER TABLE __WP_PREFIX__posts ADD COLUMN source VARCHAR(255); +# +# -- +migrate Down +# ALTER TABLE __WP_PREFIX__posts DROP COLUMN source; +# +# Injected by swap.sh: +# WP_ROOT Absolute path to the WordPress root +# MIGRATIONS_TABLE Tracking table name (pre-computed by swap.sh) +# TARGET_PREFIX Table prefix to target — the live prefix (e.g. wp_) +# BATCH Identifier grouping migrations applied by this deploy +# (the deploy's short SHA) — rollback.sh undoes one +# batch at a time. +# ============================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +QUERIES_DIR="$SCRIPT_DIR/queries" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# Extracts the "Up" or "Down" section from a migration file. A file with no +# "-- +migrate Up" marker at all is treated as one plain Up-only migration — +# prints the whole file for "up", nothing for "down". +extract_section() { + local file="$1" section="$2" + if [ "$section" = "down" ]; then + if ! grep -q '^-- +migrate Down[[:space:]]*$' "$file"; then + return 0 + fi + awk '/^-- \+migrate Down[[:space:]]*$/{flag=1; next} flag' "$file" + else + if ! grep -q '^-- +migrate Up[[:space:]]*$' "$file"; then + cat "$file" + return 0 + fi + awk '/^-- \+migrate Up[[:space:]]*$/{flag=1; next} /^-- \+migrate Down[[:space:]]*$/{flag=0} flag' "$file" + fi +} + +# ============================================================================== +# Step 1: Ensure tracking table exists (and carries the batch column, for +# tables created before that column existed). +# ============================================================================== + +wp db query " + CREATE TABLE IF NOT EXISTS \`$MIGRATIONS_TABLE\` ( + id INT AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + batch VARCHAR(8) NOT NULL DEFAULT '', + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) +" --path="$WP_ROOT" + +wp db query "ALTER TABLE \`$MIGRATIONS_TABLE\` ADD COLUMN IF NOT EXISTS batch VARCHAR(8) NOT NULL DEFAULT '' AFTER filename" --path="$WP_ROOT" + +# ============================================================================== +# Step 2: Find pending migrations +# ============================================================================== + +if [ ! -d "$QUERIES_DIR" ]; then + log "No queries directory found — nothing to migrate" + exit 0 +fi + +APPLIED=$(wp db query \ + "SELECT filename FROM \`$MIGRATIONS_TABLE\`" \ + --path="$WP_ROOT" --skip-column-names 2>/dev/null || echo "") + +PENDING=() +while IFS= read -r SQL_FILE; do + FILENAME=$(basename "$SQL_FILE") + if ! echo "$APPLIED" | grep -qxF "$FILENAME"; then + PENDING+=("$SQL_FILE") + fi +done < <(find "$QUERIES_DIR" -maxdepth 1 -name "*.sql" | sort) + +if [ "${#PENDING[@]}" -eq 0 ]; then + log "No pending migrations" + exit 0 +fi + +log "${#PENDING[@]} migration(s) to apply" + +# ============================================================================== +# Step 3: Apply pending migrations against the live tables +# ============================================================================== + +SAFE_BATCH=$(printf '%s' "$BATCH" | sed "s/'/''/g") + +for SQL_FILE in "${PENDING[@]}"; do + FILENAME=$(basename "$SQL_FILE") + log "Applying $FILENAME" + + extract_section "$SQL_FILE" up \ + | sed "s/__WP_PREFIX__/${TARGET_PREFIX}/g" \ + | wp db query --path="$WP_ROOT" + + SAFE_FILENAME=$(printf '%s' "$FILENAME" | sed "s/'/''/g") + wp db query \ + "INSERT INTO \`$MIGRATIONS_TABLE\` (filename, batch) VALUES ('$SAFE_FILENAME', '$SAFE_BATCH')" \ + --path="$WP_ROOT" + + log " Applied: $FILENAME" +done + +log "All migrations applied" diff --git a/actions/swap-and-migrate/scripts/swap.sh b/actions/swap-and-migrate/scripts/swap.sh new file mode 100644 index 0000000..c550411 --- /dev/null +++ b/actions/swap-and-migrate/scripts/swap.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# swap.sh — Atomic deploy: migrations + component swap +# +# Uploaded to the server by the swap-and-migrate action on each deploy. +# Do not copy or edit this file per-project — changes belong in the action. +# +# Migrations run directly against the live tables, in maintenance mode, with +# no table clone, prefix switch, or automated backup — a dry run against a +# disposable structure-only clone is the only pre-flight check before that +# happens. If a migration fails partway through, there is nothing to revert +# to automatically; the site stays in maintenance mode for manual recovery. +# Take a full site backup before deploying migrations, and confirm the +# patches against a staging copy first. +# +# Injected by the action: +# WP_ROOT Absolute path to the WordPress root (e.g. /home/piecode/site/public_html) +# GIT_SHA Git commit SHA for this deployment (8-character short SHA is acceptable) +# REPO_NAME GitHub repository name (used to derive migrations table name) +# +# Components are read from components.txt in the same directory, written by the +# action before this script runs. Format: one "type:name" entry per line. +# ============================================================================== + +SHORT_SHA="${GIT_SHA:0:8}" +RELEASES_DIR="${RELEASES_DIR:-$WP_ROOT/releases}" +NEW_RELEASE_DIR="$RELEASES_DIR/$GIT_SHA" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATE_SCRIPT="$SCRIPT_DIR/migrate.sh" +QUERIES_DIR="$SCRIPT_DIR/queries" +HAS_MIGRATIONS=false +MAINTENANCE_ACTIVE=false +SAFE_TO_RECOVER=true + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# Fires on any non-zero exit via set -euo pipefail. +# +# If maintenance mode was never activated, nothing to do. +# If activated and migrations haven't started yet (dry run only, or none +# pending), it is safe to deactivate — the live site is unmodified. Exit 1. +# If activated and migrations have started (SAFE_TO_RECOVER=false), the site +# must stay in maintenance mode until manually verified — there is no clone +# or automated backup to recover from. 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: Migrations may have applied only partially — before deactivating maintenance mode, verify:" + log "ERROR: wp db query \"SELECT * FROM \`$MIGRATIONS_TABLE\` ORDER BY id DESC LIMIT 5\" --path=\"$WP_ROOT\"" + log "ERROR: ls -la $WP_ROOT/wp-content/plugins/ $WP_ROOT/wp-content/themes/" + log "ERROR: To revert migrations with a '-- +migrate Down' section, run the Rollback Migrations workflow from the Actions tab rather than SSHing in" + log "ERROR: Once verified safe: wp maintenance-mode deactivate --path=\"$WP_ROOT\"" + exit 2 + fi + fi + exit $EXIT_CODE +} +trap cleanup EXIT + +# ============================================================================== +# Step 1: Pre-flight checks +# ============================================================================== + +log "Atomic deploy starting — SHA: $GIT_SHA" + +if [[ "$WP_ROOT" != '/'* ]]; then + echo "ERROR: WP_ROOT must be an absolute path starting with / (e.g. /home/piecode/site/public_html)." >&2 + exit 1 +fi + +if ! command -v wp &>/dev/null; then + echo "ERROR: wp-cli is not available on this server" >&2 + exit 1 +fi + +log "Verifying database connectivity" +wp db check --path="$WP_ROOT" + +CURRENT_PREFIX=$(wp config get table_prefix --path="$WP_ROOT") + +# CURRENT_PREFIX is interpolated into SQL string literals and identifiers +# below. WordPress's own installer already restricts table_prefix to this +# character set — enforcing it here means a misconfigured wp-config.php fails +# cleanly instead of corrupting a query or behaving like injected SQL. +if [[ ! "$CURRENT_PREFIX" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "ERROR: table_prefix '$CURRENT_PREFIX' contains unexpected characters — refusing to use it in SQL. Expected only letters, digits, and underscores, not starting with a digit." >&2 + exit 1 +fi + +# REPO_SLUG is embedded into the migrations tracking table name alongside +# CURRENT_PREFIX. MySQL caps identifiers at 64 characters, so cap REPO_SLUG +# to whatever's left, instead of a flat cut that ignores the prefix entirely. +MIGRATIONS_SUFFIX="_migrations" +MAX_SLUG_LEN=$(( 64 - ${#CURRENT_PREFIX} - ${#MIGRATIONS_SUFFIX} )) +if [ "$MAX_SLUG_LEN" -lt 1 ]; then + echo "ERROR: table_prefix '$CURRENT_PREFIX' is too long to derive a migrations table name within MySQL's 64-character identifier limit." >&2 + exit 1 +fi +REPO_SLUG="$(printf '%s' "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' | cut -c1-"$MAX_SLUG_LEN")" + +MIGRATIONS_TABLE="${CURRENT_PREFIX}${REPO_SLUG}_migrations" + +if [ ! -d "$NEW_RELEASE_DIR" ]; then + echo "ERROR: Release directory $NEW_RELEASE_DIR not found — did all rsync jobs complete?" >&2 + exit 1 +fi + +COMPONENTS_FILE="$SCRIPT_DIR/components.txt" +if [ ! -f "$COMPONENTS_FILE" ]; then + echo "ERROR: components.txt not found at $COMPONENTS_FILE" >&2 + exit 1 +fi + +readarray -t COMPONENTS < <(grep -v '^[[:space:]]*$' "$COMPONENTS_FILE") + +if [ "${#COMPONENTS[@]}" -eq 0 ]; then + echo "ERROR: No components defined in components.txt" >&2 + exit 1 +fi + +# TYPE and NAME are extracted from each entry below and built into paths that +# are later passed to mv/rm -rf. Restricting to slug-safe characters up front +# rules out a stray '/' or '..' steering those destructive calls outside +# wp-content/, however the entry ended up malformed. +for COMPONENT in "${COMPONENTS[@]}"; do + if [[ ! "$COMPONENT" =~ ^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$ ]]; then + echo "ERROR: Invalid component entry '$COMPONENT' — expected type:name using only letters, digits, hyphens, and underscores." >&2 + exit 1 + fi +done + +log "Validating component release paths" +for COMPONENT in "${COMPONENTS[@]}"; do + NAME="${COMPONENT##*:}" + RELEASE_PATH="$NEW_RELEASE_DIR/$NAME" + if [ ! -d "$RELEASE_PATH" ]; then + echo "ERROR: $RELEASE_PATH not found — did the rsync job for $NAME complete?" >&2 + exit 1 + fi +done + +# ============================================================================== +# Step 2: Detect pending migrations +# ============================================================================== + +PENDING_FILES=() + +if [ -d "$QUERIES_DIR" ]; then + APPLIED=$(wp db query \ + "SELECT filename FROM \`$MIGRATIONS_TABLE\`" \ + --path="$WP_ROOT" --skip-column-names 2>/dev/null || echo "") + + while IFS= read -r SQL_FILE; do + FILENAME=$(basename "$SQL_FILE") + if ! echo "$APPLIED" | grep -qxF "$FILENAME"; then + PENDING_FILES+=("$SQL_FILE") + fi + done < <(find "$QUERIES_DIR" -maxdepth 1 -name "*.sql" | sort) +fi + +if [ "${#PENDING_FILES[@]}" -gt 0 ]; then + HAS_MIGRATIONS=true + log "${#PENDING_FILES[@]} pending migration(s) found" +else + log "No pending migrations" +fi + +# ============================================================================== +# Step 3: Database migrations — applied directly against the live tables +# ============================================================================== + +if [ "$HAS_MIGRATIONS" = true ]; then + + log "Enabling maintenance mode" + wp maintenance-mode activate --path="$WP_ROOT" + MAINTENANCE_ACTIVE=true + + # ------------------------------------------------------------------ + # Dry run: validate pending migration patches before touching live + # tables. Patches are applied to structure-only clones of the live + # tables (columns/indexes, no rows) under a throwaway prefix, then + # those clones are dropped immediately. This is the only pre-flight + # check before migrations run directly against production — there is + # no table clone or backup to fall back to if a patch turns out to be + # broken. A data-dependent patch (e.g. an UPDATE matching on row + # content) can still pass here and fail for other reasons later, + # since no rows exist yet to match against. + # ------------------------------------------------------------------ + log "Dry run: validating ${#PENDING_FILES[@]} pending migration(s) against a structure-only clone" + + DRYRUN_PREFIX="dryrun_${SHORT_SHA}_" + DRYRUN_SOURCE_TABLES=$(wp db query \ + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = DATABASE() \ + AND LEFT(table_name, CHAR_LENGTH('${CURRENT_PREFIX}')) = '${CURRENT_PREFIX}'" \ + --path="$WP_ROOT" --skip-column-names) + + set +e + DRYRUN_FAILED=false + + # Clear any remnants from a previous failed attempt at this SHA, then clone structure only. + while IFS= read -r TABLE; do + [ -z "$TABLE" ] && continue + DRYRUN_TABLE="${DRYRUN_PREFIX}${TABLE#$CURRENT_PREFIX}" + wp db query "DROP TABLE IF EXISTS \`$DRYRUN_TABLE\`" --path="$WP_ROOT" || true + wp db query "CREATE TABLE \`$DRYRUN_TABLE\` LIKE \`$TABLE\`" --path="$WP_ROOT" || DRYRUN_FAILED=true + done <<< "$DRYRUN_SOURCE_TABLES" + + if [ "$DRYRUN_FAILED" = false ]; then + for SQL_FILE in "${PENDING_FILES[@]}"; do + FILENAME=$(basename "$SQL_FILE") + log "Dry run: applying $FILENAME" + if ! sed "s/__WP_PREFIX__/${DRYRUN_PREFIX}/g" "$SQL_FILE" | wp db query --path="$WP_ROOT"; then + echo "ERROR: Dry run failed applying $FILENAME" >&2 + DRYRUN_FAILED=true + break + fi + done + fi + + log "Dry run: cleaning up scratch tables" + while IFS= read -r TABLE; do + [ -z "$TABLE" ] && continue + DRYRUN_TABLE="${DRYRUN_PREFIX}${TABLE#$CURRENT_PREFIX}" + wp db query "DROP TABLE IF EXISTS \`$DRYRUN_TABLE\`" --path="$WP_ROOT" || true + done <<< "$DRYRUN_SOURCE_TABLES" + set -e + + if [ "$DRYRUN_FAILED" = true ]; then + echo "ERROR: Dry run detected a migration failure — bailing out before touching live tables. No live data was touched." >&2 + exit 1 + fi + + log "Dry run passed" + + # ------------------------------------------------------------------ + # Point of no return — migrations are about to run against the live + # tables directly, with no clone or backup to fall back to. 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 "Applying migrations against live tables (prefix '$CURRENT_PREFIX')" + WP_ROOT="$WP_ROOT" \ + MIGRATIONS_TABLE="$MIGRATIONS_TABLE" \ + TARGET_PREFIX="$CURRENT_PREFIX" \ + BATCH="$SHORT_SHA" \ + bash "$MIGRATE_SCRIPT" + + log "Database migrations complete" +fi + +# ============================================================================== +# Step 4: Component swap +# +# Each component is rsynced to a hidden staging directory, then atomically +# renamed into place. WordPress ignores directories starting with '.', so +# the staging copy is never served during the transfer. +# ============================================================================== + +log "Deploying components for release $GIT_SHA" + +mkdir -p "$RELEASES_DIR" + +for COMPONENT in "${COMPONENTS[@]}"; do + TYPE="${COMPONENT%%:*}" + NAME="${COMPONENT##*:}" + LIVE_PATH="$WP_ROOT/wp-content/$TYPE/$NAME" + RELEASE_PATH="$NEW_RELEASE_DIR/$NAME" + STAGING_PATH="${LIVE_PATH}.deploying" + OLD_PATH="${LIVE_PATH}.previous" + + # Clear any remnants from a previous failed deploy + rm -rf "$STAGING_PATH" "$OLD_PATH" + + # Rsync to a hidden staging directory not yet visible to WordPress + mkdir -p "$STAGING_PATH" + rsync -a --delete "$RELEASE_PATH/" "$STAGING_PATH/" + + # Atomic rename: live → .previous, staging → live + if [ -e "$LIVE_PATH" ] || [ -L "$LIVE_PATH" ]; then + mv "$LIVE_PATH" "$OLD_PATH" + fi + mv "$STAGING_PATH" "$LIVE_PATH" + rm -rf "$OLD_PATH" + + log " $TYPE/$NAME -> $RELEASE_PATH" +done + +# ============================================================================== +# Step 5: Disable maintenance mode +# +# Done before pruning so the site comes back up even if cleanup fails. +# MAINTENANCE_ACTIVE is set to false regardless — the cleanup trap must not +# attempt a second deactivation after this point. +# ============================================================================== + +if [ "$MAINTENANCE_ACTIVE" = true ]; then + log "Disabling maintenance mode" + if ! wp maintenance-mode deactivate --path="$WP_ROOT"; then + log "ERROR: Failed to deactivate maintenance mode — run manually:" + log "ERROR: wp maintenance-mode deactivate --path=\"$WP_ROOT\"" + exit 2 + fi + MAINTENANCE_ACTIVE=false +fi + +# ============================================================================== +# Step 6: Prune old releases — keep current + 1 prior +# ============================================================================== + +log "Pruning old releases" + +while IFS= read -r OLD_RELEASE; do + log " Removing $OLD_RELEASE" + rm -rf "$OLD_RELEASE" || log "WARN: Could not remove $OLD_RELEASE — manual cleanup may be needed" +done < <(find "$RELEASES_DIR" -maxdepth 1 -mindepth 1 -type d \ + ! -name "$GIT_SHA" ! -name "initial" \ + -printf '%T@ %p\n' | sort -rn | tail -n +2 | cut -d' ' -f2-) + +log "Atomic deploy complete — $GIT_SHA is live" diff --git a/templates/migrations/queries/.gitkeep b/templates/migrations/queries/.gitkeep new file mode 100644 index 0000000..e69de29