CP Static Deploy
ClassicPress/WordPress plugin for automated static site deployment via wget, PHP post-processing, and GitHub PRs
by seriocomic · github.com/seriocomic/cp-static-deploy · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/seriocomic/cp-static-deploy/archive/refs/heads/main.zipA ClassicPress/WordPress plugin that automates static site deployment. It mirrors a dev site via wget, processes the output (URL rewriting, feed conversion, minification), and deploys to GitHub Pages or Cloudflare Pages via pull request.
How It Works
Content Published on Dev Site
| Plugin hooks (publish_post, publish_page, post_updated)
WordPress REST API Change Detection
| Queries /wp-json/wp/v2/posts and /pages with modified_after
Selective or Full wget Mirror
| Only changed pages + dependencies, or full site crawl
PHP Post-Processing Pipeline
| URL rewriting, feed processing, robots.txt, HTML minification
Git Commit + Push to Staging Branch
| GitHub REST API creates/updates Pull Request
Auto-Merge via GitHub Actions
| Hosting platform deploys from production branch
Production Site Updated
Features
- Selective builds - Only rebuilds changed pages using the WordPress REST API, reducing build time from ~30 minutes to ~30 seconds
- No external dependencies - Replaces Node.js/npm/gulp/GitHub CLI with pure PHP post-processing and the WordPress HTTP API
- Configurable via admin UI - All settings (URLs, credentials, branches, build options) managed through a tabbed settings page
- Encrypted credential storage - GitHub token encrypted with AES-256-CBC using WordPress auth keys
- Admin status page - Real-time deploy status, logs, and manual trigger with AJAX polling
- Automatic merge conflict resolution - Pre-syncs with production branch and auto-resolves conflicts
- Lock file protection - Prevents concurrent deploys
- Fallback to full mirror - If selective wget fails, automatically falls back to full site crawl
Requirements
- ClassicPress 1.0+ or WordPress 4.9+
- PHP 7.4+ with OpenSSL extension (for token encryption)
gitandwgetinstalled on the server- A GitHub repository for the static site output
- A system user (the "deploy user") with SSH key access to GitHub and write access to the working directory
- A
sudoersentry allowing the web server user to run the deploy script as the deploy user
Installation
Step 1: Upload the Plugin
Upload the cp-static-deploy directory to wp-content/plugins/ and activate via the ClassicPress/WordPress admin. On activation, the plugin creates a working directory at wp-content/static-deploy/ with subdirectories for build output, the git repo, and logs.
Step 2: Working Directory Permissions
The working directory must be owned by the deploy user and writable by the web server group. This allows the deploy process (running as the deploy user) to write build output, and the web server (running as www-data) to read logs and status for the admin UI.
# Replace DEPLOY_USER with the system user configured in plugin settings
sudo chown -R DEPLOY_USER:www-data /path/to/wp-content/static-deploy
sudo chmod -R 775 /path/to/wp-content/static-deploy
Why DEPLOY_USER:www-data? The deploy process runs as the deploy user (via sudo) and needs to write files. The web server runs as www-data and needs to read log files for the admin status page. The 775 permission allows both.
Step 3: Git Repository
Clone the static site GitHub repository into the working directory. This is where the deploy process commits and pushes build output.
cd /path/to/wp-content/static-deploy
sudo -u DEPLOY_USER git clone git@github.com:owner/repo.git repo
cd repo
sudo -u DEPLOY_USER git checkout staging # or create: git checkout -b staging
sudo -u DEPLOY_USER git config user.name "Deploy Bot"
sudo -u DEPLOY_USER git config user.email "deploy@example.com"
Why clone as the deploy user? The repo directory must be owned by the deploy user so git operations work during automated deploys. Running the clone as the deploy user also ensures the SSH key authentication is tested.
Step 4: SSH Key for Git Push
The deploy user needs an SSH key registered with GitHub to push commits. This avoids putting tokens in shell commands.
# Generate a key (as the deploy user)
sudo -u DEPLOY_USER ssh-keygen -t ed25519 -f ~DEPLOY_USER/.ssh/id_deploy -N "" -C "deploy@example.com"
# Add the public key to GitHub:
# 1. Copy the output of: sudo -u DEPLOY_USER cat ~DEPLOY_USER/.ssh/id_deploy.pub
# 2. Go to GitHub repo > Settings > Deploy keys > Add deploy key
# 3. Check "Allow write access"
# Configure SSH to use this key for github.com
sudo -u DEPLOY_USER bash -c 'cat >> ~/.ssh/config << EOF
Host github.com
IdentityFile ~/.ssh/id_deploy
IdentitiesOnly yes
EOF'
# Test the connection
sudo -u DEPLOY_USER ssh -T git@github.com
Why SSH keys instead of tokens? SSH keys are scoped to the deploy user on the server. The GitHub token stored in plugin settings is used only for the GitHub REST API (PR creation), not for git push operations.
Step 5: Sudoers Configuration
The web server process (running as www-data) needs to execute the deploy script as the deploy user without a password prompt. This is configured via a sudoers drop-in file.
Why is this needed? When content is published, ClassicPress triggers the deploy via PHP's exec(). PHP runs as www-data, but the deploy process needs to run as the deploy user because that user owns the working directory, the git repo, and the SSH keys for pushing to GitHub.
What gets allowed? Two specific commands, nothing else:
bash /path/to/run-deploy.sh- The actual deploy scripttrue- Used by the plugin's Prerequisites check to verify sudo access is configured
# Create the sudoers file (replace paths and users as needed)
echo 'www-data ALL=(DEPLOY_USER) NOPASSWD: /usr/bin/bash /path/to/wp-content/static-deploy/run-deploy.sh, /usr/bin/true' | sudo tee /etc/sudoers.d/static-deploy
# Set correct permissions (required - sudo ignores files that aren't 0440)
sudo chmod 440 /etc/sudoers.d/static-deploy
# Validate syntax (critical - a syntax error here can lock out sudo entirely)
sudo visudo -cf /etc/sudoers.d/static-deploy
Expected output: /etc/sudoers.d/static-deploy: parsed OK
Verify it works:
# Test as www-data (should produce no output and exit 0)
sudo -u www-data sudo -n -u DEPLOY_USER true
# Test the actual deploy command
sudo -u www-data sudo -n -u DEPLOY_USER bash /path/to/wp-content/static-deploy/run-deploy.sh
Common issues:
| Symptom | Cause | Fix |
|---|---|---|
sudo: a password is required |
No sudoers entry exists | Create the file as shown above |
sudo: no tty present |
Missing -n flag or wrong command |
Verify the exact command path matches the sudoers entry |
| Prerequisite check fails but deploys work | Sudoers entry missing /usr/bin/true |
Add /usr/bin/true to the allowed commands |
parsed OK not shown |
Syntax error in sudoers file | Delete the file and recreate it carefully |
Where is this file? /etc/sudoers.d/static-deploy - This directory is included by the main /etc/sudoers file on Debian/Ubuntu systems. Files here are loaded automatically.
Step 6: GitHub Configuration
Personal Access Token
Generate a GitHub Personal Access Token (classic) with repo scope:
- Go to GitHub > Settings > Developer settings > Personal access tokens > Tokens (classic)
- Click "Generate new token (classic)"
- Set a descriptive name (e.g. "CP Static Deploy")
- Select the
reposcope (full control of private repositories) - Generate and copy the token
- Paste it into the plugin settings (Settings > Static Deploy > GitHub tab)
The token is encrypted with AES-256-CBC before storage in the database.
Staging Branch
Create a staging branch in the GitHub repository if it doesn't exist:
cd /path/to/wp-content/static-deploy/repo
sudo -u DEPLOY_USER git checkout -b staging
sudo -u DEPLOY_USER git push -u origin staging
Auto-Merge Label
Create a label named auto-merge in the GitHub repository:
- Go to the repository > Issues > Labels
- Click "New label"
- Name:
auto-merge, Color:#0E8A16(green)
This label is added to PRs by the plugin and triggers the auto-merge workflow.
GitHub Actions Workflow
Create .github/workflows/auto-merge.yml in the static site repository to handle automatic merging of deploy PRs:
name: Auto-Merge Staging to Master
on:
pull_request:
types: [labeled, synchronize, opened, reopened]
jobs:
auto-merge:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'auto-merge') &&
github.event.pull_request.base.ref == 'master'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Wait for status checks
uses: lewagon/wait-on-check-action@v1.3.1
with:
ref: ${{ github.event.pull_request.head.sha }}
check-name: 'auto-merge'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
allowed-conclusions: success,skipped,neutral
continue-on-error: true
- name: Auto-merge PR
uses: pascalgn/automerge-action@v0.16.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MERGE_LABELS: "auto-merge"
MERGE_METHOD: "squash"
MERGE_COMMIT_MESSAGE: "pull-request-title"
MERGE_DELETE_BRANCH: false
UPDATE_LABELS: ""
notify-failure:
runs-on: ubuntu-latest
needs: auto-merge
if: failure()
steps:
- name: Comment on PR about failure
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Auto-merge failed. Check the workflow logs and merge manually.'
})
Configuration
Navigate to Settings > Static Deploy in the admin panel.
General Tab
| Setting | Description |
|---|---|
| Source URL | Dev site URL to crawl (e.g. https://dev.example.com) |
| Production URL | URL to rewrite dev URLs to (e.g. https://www.example.com) |
| Exclude Domains | Comma-separated domains to exclude from wget (e.g. assets.example.com) |
| Deploy User | System user that runs the deploy process |
| Auto Deploy | Toggle automatic deploy on publish/update |
GitHub Tab
| Setting | Description |
|---|---|
| Repository | GitHub repo in owner/repo format |
| Personal Access Token | Token with repo scope (stored encrypted) |
| Staging Branch | Branch for deploy commits (default: staging) |
| Production Branch | Branch PRs merge into (default: master) |
| Auto-Merge Label | Label added to PRs for auto-merge (default: auto-merge) |
Build Tab
| Setting | Description |
|---|---|
| Cache Clean Pages | Comma-separated paths to delete from wget cache before each build |
| Selective Threshold | Max changed items before using full rebuild instead of selective |
| Extra wget Arguments | Additional flags (e.g. --no-check-certificate) |
| Robots.txt | Custom content with {{production_url}} placeholder support |
| README Content | Optional README.md included in the repository |
Prerequisites Tab
Displays system check results for git, wget, working directory, repository, deploy user, sudo access, GitHub token, and source URL. All checks should show green before attempting a deploy.
Help Tab
In-app reference covering setup instructions, sudoers configuration, troubleshooting, and FAQ.
Usage
Automatic Deploy
When Auto Deploy is enabled, the plugin triggers a deploy whenever a post or page is published or updated. An admin notice appears with a link to the status page.
Manual Deploy
Navigate to Tools > Static Deploy and click Trigger Manual Deploy. The status page shows real-time progress with automatic log refresh.
Command Line
# As the deploy user directly
bash /path/to/wp-content/static-deploy/run-deploy.sh
# Via SSH
ssh user@server "bash /path/to/wp-content/static-deploy/run-deploy.sh"
Post-Processing Pipeline
The PHP processor replaces the traditional gulp/Node.js build pipeline:
- Clean wget cache - Removes key HTML files so wget fetches fresh copies
- wget mirror - Full site crawl or selective download of changed URLs
- HTML URL rewriting - Domain swap, quote normalization, self-closing tag cleanup, newline removal, feed URL rewrite
- XML/RSS URL rewriting - Domain swap on
.xmland.rssfiles - Feed processing - Updates GUIDs via SimpleXML, renames
feed/index.htmltofeed/all.rss - robots.txt generation - From configurable template
- README generation - Optional README.md
- Old file cleanup - Removes converted
feed/index.htmlfiles - Copy to repo - Recursive copy from build directory to git repository
- Numbered file cleanup - Removes
sitemap.xml.1etc.
Build Strategy
| Condition | Strategy | Duration |
|---|---|---|
No .last-build-time file |
Full mirror | ~30 min (first run) |
| Changes > selective threshold | Full mirror | ~50 sec (cached) |
| Changes <= threshold | Selective (changed URLs + dependencies) | ~30 sec |
| No changes detected | Skip (no deploy) | ~2 sec |
The wget cache persists across builds in wp-content/static-deploy/build/, so even full mirrors are fast after the initial crawl (wget uses 304 Not Modified responses).
PR Behavior
- New PR is created when no open PR exists from staging to production branch
- Existing PR is updated (title refreshed) when commits are pushed while a PR is still open
- The auto-merge label is added in both cases
- A GitHub Actions workflow (not included in this plugin) handles the merge
File Structure
wp-content/plugins/cp-static-deploy/
|- cp-static-deploy.php # Entry point, hooks, activation
|- includes/
| |- class-settings.php # Settings, encryption, prerequisites
| |- class-deployer.php # Deploy orchestrator
| |- class-processor.php # Post-processing pipeline
| |- class-github.php # GitHub REST API wrapper
| +- class-admin.php # Admin UI, AJAX handlers
|- templates/
| |- settings-page.php # Settings form
| +- status-page.php # Deploy status and logs
+- assets/
+- css/admin.css # Admin styles
wp-content/static-deploy/ # Working directory (outside plugin)
|- build/ # wget output (persistent cache)
|- repo/ # Git repository clone
|- logs/ # deploy.log + trigger.log
|- run-deploy.sh # Bash wrapper for sudo execution
|- .last-build-time # ISO timestamp of last build
+- .lock # Present during active deploy
Security
- GitHub token encrypted at rest with AES-256-CBC using WordPress
AUTH_KEYandAUTH_SALT - SSH keys used for git push (no tokens in shell commands)
escapeshellarg()on all shell arguments- AJAX nonce verification on all admin endpoints
- Background execution prevents PHP timeout
- Lock file prevents concurrent deploys
- Sudoers entry restricted to two specific commands
Privacy & Data Collection
This plugin is designed with privacy as a priority. Here's what data is collected and transmitted:
Data Stored Locally
- GitHub Personal Access Token: Stored encrypted in the WordPress database using AES-256-CBC encryption. The token is only decrypted in memory when making GitHub API calls and is never transmitted to any server other than GitHub's official API.
- Plugin Settings: All configuration (URLs, branch names, deploy user, etc.) is stored in the WordPress options table.
- Deploy Logs: Timestamped log entries of deploy operations stored locally at
wp-content/static-deploy/logs/. - Build Cache: Static site mirror stored locally at
wp-content/static-deploy/build/for faster subsequent builds.
External Server Communication
This plugin requires explicit opt-in consent before communicating with external servers. You must manually configure and authorize all external connections:
-
GitHub API (api.github.com)
- When: Only when you provide a GitHub Personal Access Token in plugin settings
- What data is sent: Repository name, pull request metadata (title, branch names, labels)
- Why: To create and manage pull requests for static site deployments
- User consent: Required - you must generate and manually enter a GitHub token
- No identifiable data: Only repository content and commit metadata is transmitted
-
WordPress REST API (Local Server)
- When: During selective build process
- What data is accessed: Post and page metadata (titles, URLs, modification dates)
- Why: To detect which content has changed since the last build
- Where: Queries your own WordPress installation (
/wp-json/wp/v2/postsand/pages) - No external transmission: This data never leaves your server
What is NOT Collected
- No user browsing data
- No visitor analytics or tracking
- No personal information about site administrators or authors
- No content is transmitted to third-party services (other than GitHub for deployment)
- No telemetry, phone-home features, or usage statistics
Third-Party Services
The plugin integrates with GitHub only. You must have:
- A GitHub account
- A GitHub repository for static site output
- A GitHub Personal Access Token with
reposcope
GitHub's privacy policy applies to data transmitted to their API: https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement
Data Retention
- Deploy logs are retained indefinitely until manually deleted
- Build cache is retained until manually cleared or the working directory is deleted
- GitHub token remains encrypted in the database until you change or remove it via plugin settings
Your Control
You maintain full control over all data:
- Deactivating the plugin stops all external communication immediately
- Deleting the plugin removes all settings and the encrypted GitHub token
- Build cache and logs can be manually deleted at any time from
wp-content/static-deploy/
Troubleshooting
Prerequisites check shows "Sudo not configured"
The sudoers entry is missing or doesn't include /usr/bin/true. See Step 5: Sudoers Configuration.
Deploy triggered but nothing happens
Check the trigger log and deploy log:
tail -20 /path/to/wp-content/static-deploy/logs/trigger.log
tail -50 /path/to/wp-content/static-deploy/logs/deploy.log
Common causes: lock file from a previous failed deploy (delete .lock), deploy user can't write to working directory (fix permissions), SSH key not accepted by GitHub (test with ssh -T git@github.com).
Deploy stuck (lock file persists)
# Check if deploy process is still running
ps aux | grep run-deploy
# If not running, remove the stale lock
rm /path/to/wp-content/static-deploy/.lock
PR not auto-merging
- Verify the
auto-mergelabel exists in the GitHub repository - Check GitHub Actions is enabled and the workflow file exists at
.github/workflows/auto-merge.yml - View workflow runs at the repository's Actions tab
wget fails or times out
- Verify the source URL is reachable:
curl -I https://dev.example.com - Check disk space:
df -h /var/www - For self-signed certificates, add
--no-check-certificateto Extra wget Arguments in Build settings
GitHub API errors
Test the connection via Settings > Static Deploy > Prerequisites tab, or check the deploy log for specific error messages. Common causes: expired token, incorrect repository format, network issues.
License
GPL-2.0+