Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ jobs:
run: node --test --test-concurrency=1 storage.test.js router-policy.test.js sqliteS3.concurrency.test.js sqliteVercelBlob.etag.test.js sqliteVercelBlob.auth.test.js sqliteVercelBlob.concurrency.test.js
working-directory: ./test

# The WordPress update rules. These decide which files an update
# overwrites and deletes in a fork, so they run before anything slow.
- name: WordPress updater unit tests
run: node --test wpUpdate.plan.test.js wpUpdate.apply.test.js wpUpdate.plugins.test.js wpUpdate.themes.test.js wpUpdate.github.test.js
working-directory: ./test

- run: ./run-db-test.sh
working-directory: ./test

Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Boots WordPress in a container and runs the version-agnostic smoke tests
# against it. Unlike the e2e suite this has no owner guard, so it runs in every
# copy of the repository, and it skips Playwright -- the smoke checks assert only
# what WordPress keeps stable across releases, so they stay green through an
# update that would break the e2e selectors.
name: Smoke tests
on: [push, pull_request]

jobs:
smoke:
name: Smoke tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22.x

- run: ./run-smoke-test.sh
working-directory: ./test
77 changes: 66 additions & 11 deletions .github/workflows/update-wp.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,80 @@
# Opens a pull request when a new WordPress release comes out. Runs in every
# copy, since a deployed site's repo has nobody logged in and DISALLOW_FILE_MODS
# keeps wp-admin from offering the update. Two things must be enabled by hand per
# copy -- see "Keeping WordPress updated" in the readme.
name: Update WordPress
on:
schedule:
- cron: "0 0 * * *"
# Evening pass, then a fallback twelve hours later. UTC (cron ignores DST); the
# odd minute dodges GitHub's dropped on-the-hour queue.
- cron: "10 21 * * *"
- cron: "10 9 * * *"
workflow_dispatch:

# New repos default GITHUB_TOKEN to read-only, so without this the push fails.
permissions:
contents: write
pull-requests: write

jobs:
update:
name: Update WordPress
runs-on: ubuntu-latest
if: github.repository_owner == 'mitchmac'
steps:
- name: Checkout
uses: actions/checkout@v3
- run: ./upgrade-wp.sh
working-directory: ./util
uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22.x

- name: Update WordPress
id: update
run: node util/wp-update --report "${{ runner.temp }}/wp-update.md"

# The report explains what was left untouched and why, so it's the PR body.
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
if: steps.update.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
with:
commit-message: Automated WordPress version update
title: WordPress version update
body: 'This is an automated update of the bundled WordPress files'
committer: "Mitch MacKenzie <mitchmac@gmail.com>"
assignees: mitchmac
commit-message: "WordPress ${{ steps.update.outputs.to }}"
title: "Update WordPress to ${{ steps.update.outputs.to }}"
body-path: ${{ runner.temp }}/wp-update.md
delete-branch: true
branch: 'wordpress-version-update'

# Separate job and branch, so a plugin update never mixes into a core one and
# either can be merged or reverted on its own.
plugins:
name: Update plugins
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22.x

- name: Update plugins
id: update
run: node util/wp-update --plugins --report "${{ runner.temp }}/plugin-update.md"

- name: Create Pull Request
if: steps.update.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
with:
commit-message: "Update bundled plugins"
title: "Update ${{ steps.update.outputs.plugins }} bundled plugin(s)"
body-path: ${{ runner.temp }}/plugin-update.md
delete-branch: true
branch: 'plugin-version-update'

# Themes are report-only (no checksums to verify them), so with nothing to
# commit the findings go to the run summary. Never fails the job.
- name: Report on themes
if: always()
continue-on-error: true
run: node util/wp-update --themes
26 changes: 26 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,32 @@ The most explicitly configured option wins, so adding a Blob store for media won
- WordPress and its files are in the ```/wp``` directory. You can add plugins or themes there in their respective directories in ```wp-content``` then commit the files to your repository so it will re-deploy.
- Plugins like [Cache-Control](https://wordpress.org/plugins/cache-control/) can enable CDN caching with the s-maxage directive and make your site super fast. Refer to [Vercel Edge Caching](https://vercel.com/docs/concepts/edge-network/caching) or [Netlfiy Cache Headers](https://docs.netlify.com/edge-functions/optional-configuration/#supported-headers)

## Keeping WordPress updated
WordPress lives in your repository, so updates arrive as a pull request instead of through wp-admin. The **Update WordPress** action checks daily for a new release and opens a pull request against your default branch; merging it re-deploys your site.

Two settings need turning on once in your own copy of the repository:

1. **Settings → Actions → General → Workflow permissions**, tick *Allow GitHub Actions to create and approve pull requests*. GitHub leaves this off by default and it cannot be enabled from a workflow file. Without it the branch is still pushed, so you can open the pull request yourself.
2. **Actions → Update WordPress → Enable workflow**, if GitHub has disabled it. Scheduled workflows are switched off automatically after 60 days without a push — the normal state of a site repository. You can also run the update at any time with **Run workflow**.

The update only replaces files that WordPress itself ships, and it checks each one against the checksums wordpress.org publishes before touching it. Your themes, plugins, uploads and `wp-config.php` are never candidates, and neither is a bundled file you have edited or deleted.

The pull request body lists anything the update skipped and why, along with any core file that differs from what WordPress ships — so an edit you made to WordPress itself shows up before a later release collides with it.

Bundled plugins are updated the same way, in a **separate** pull request, so a plugin update never rides along with a core one and either can be reverted on its own. A plugin is only replaced when wordpress.org can prove file by file that what's on disk is exactly the release it claims to be — so your own plugins, anything premium, and anything bundled from outside wordpress.org are left alone and listed in the pull request instead. If even one file of a plugin has been edited, the whole plugin is skipped rather than left running a mix of two releases.

One exception: **SQLite Database Integration** is bundled from its GitHub repository rather than wordpress.org, and follows that repository's default branch. wordpress.org carries an older release of it, so there is nothing to check it against and the pull request diff is the review. Because the copy mirrors the branch, files you add inside that plugin's directory are removed by an update — keep your own code in its own plugin.

Themes are only ever **reported on**, never updated. wordpress.org publishes no checksums for themes, so there is no way to tell a theme you have edited from an untouched one, and overwriting it would risk your work. Themes bundled with WordPress are excluded from the report because the core update already covers them. Anything else with a newer release is listed in the workflow run summary, and updating it is a manual step.

To check any of this without changing anything:

```bash
node util/wp-update --dry-run
node util/wp-update --plugins --dry-run
node util/wp-update --themes
```

## Customizing ServerlessWP
- `netlify.toml` or `vercel.json` are where we configure ```/api/index.js``` to handle all requests
- [mitchmac/serverlesswp-node](https://github.com/mitchmac/serverlesswp-node) is used to run PHP and handle the request
Expand Down
75 changes: 75 additions & 0 deletions test/run-smoke-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/bin/bash
set -euo pipefail

# Boots the app in SQLite+S3 mode against a local MinIO and runs the
# version-agnostic smoke tests (smoke.test.js) against it -- no Playwright, no
# owner guard. This is the check that survives a WordPress bump: the e2e suite's
# selectors break on a new release, but a green here means WordPress still boots
# and serves. The boot mirrors run-s3-test.sh; only the tests differ.

./build-test.sh

# Clean up any leftovers from a previous run
pkill -f "node proxy.js" 2>/dev/null || true
docker stop serverlesswp-test minio 2>/dev/null || true
docker rm serverlesswp-test minio 2>/dev/null || true
docker network rm serverlesswp-test-network 2>/dev/null || true

VERCEL=${VERCEL:-1}
VERCEL_GIT_COMMIT_REF=${VERCEL_GIT_COMMIT_REF:-test_branch}

if ! command -v mc &> /dev/null; then
wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc
chmod +x /usr/local/bin/mc
fi

docker network create serverlesswp-test-network

docker run -d --name minio \
--network serverlesswp-test-network \
-p 9010:9000 -p 9011:9011 \
-e "MINIO_ROOT_USER=minioadmin" -e "MINIO_ROOT_PASSWORD=minioadmin" \
minio/minio server /data --console-address ":9011"

sleep 5

mc alias set local-minio http://localhost:9010 minioadmin minioadmin
mc mb local-minio/test-bucket
mc admin user add local-minio testuser testpass
mc admin policy attach local-minio readwrite --user testuser
mc anonymous set download local-minio/test-bucket

docker run \
-e SQLITE_S3_BUCKET=test-bucket \
-e SQLITE_S3_API_KEY=testuser -e SQLITE_S3_API_SECRET=testpass \
-e SQLITE_S3_REGION=us-east-1 -e SQLITE_S3_ENDPOINT=http://minio:9000 -e SQLITE_S3_FORCE_PATH_STYLE=1 \
-e VERCEL=$VERCEL -e VERCEL_GIT_COMMIT_REF=$VERCEL_GIT_COMMIT_REF \
-e SERVERLESSWP_TESTING=1 \
-e SERVERLESSWP_READ_ONLY_MODE=false \
-p 9000:8080 \
--network serverlesswp-test-network \
-d --name serverlesswp-test serverlesswp-test

node proxy.js > /dev/null 2>&1 &
PROXY_PID=$!

cleanup() {
kill $PROXY_PID 2>/dev/null || true
docker stop serverlesswp-test 2>/dev/null || true
docker rm serverlesswp-test 2>/dev/null || true
docker stop minio 2>/dev/null || true
docker rm minio 2>/dev/null || true
docker network rm serverlesswp-test-network 2>/dev/null || true
}
trap cleanup EXIT

until curl -sfko /dev/null https://localhost:3000/; do sleep 1; done

# The fresh SQLite database has no WordPress tables yet, so every request
# redirects to the installer. installer.php (baked into the test image, gated by
# SERVERLESSWP_TESTING) creates them, the same way the Playwright setup does.
echo "Installing WordPress..."
curl -sfk https://localhost:3000/installer.php > /dev/null

echo "Running smoke tests..."
SMOKE_INSECURE=1 node --test smoke.test.js
89 changes: 89 additions & 0 deletions test/smoke.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Version-agnostic smoke tests. These assert only stable WordPress contracts --
// HTTP status, the REST API shape, the login form's field names -- never the
// wp-admin or theme markup that shifts between releases. So the same file passes
// across WordPress versions and runs anywhere a site is reachable, not just the
// test container: point it at a deployment with SMOKE_BASE_URL.
//
// node --test smoke.test.js # local container
// SMOKE_BASE_URL=https://example.com node --test smoke.test.js
//
// Read-only on purpose: nothing here writes, so it is safe against a live site.
// SMOKE_INSECURE=1 (implied for localhost) accepts the self-signed test cert.

const { test, before } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const https = require('node:https');

const BASE = (process.env.SMOKE_BASE_URL || 'https://localhost:3000').replace(/\/+$/, '');
const INSECURE = process.env.SMOKE_INSECURE === '1' || /^https:\/\/(localhost|127\.0\.0\.1)/.test(BASE);

const FATAL = /(Fatal error|Parse error|Uncaught \w*Error|There has been a critical error)/i;

function get(pathname, { headers = {} } = {}) {
const url = new URL(pathname, BASE + '/');
const client = url.protocol === 'https:' ? https : http;

return new Promise((resolve, reject) => {
const req = client.get(url, { headers, rejectUnauthorized: !INSECURE }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => resolve({
status: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks).toString('utf8'),
}));
});
req.on('error', reject);
req.setTimeout(30000, () => req.destroy(new Error(`timed out after 30s requesting ${url.href}`)));
});
}

// One reachability probe up front, so a site that is down reads as that rather
// than every test failing with a connection error.
before(async () => {
try {
await get('/');
} catch (error) {
throw new Error(`${BASE} is not reachable: ${error.message}`);
}
});

test('homepage renders an HTML document', async () => {
const res = await get('/');
assert.equal(res.status, 200, `expected 200 from /, got ${res.status}`);
assert.match(res.body, /<html/i, 'homepage is not HTML');
assert.match(res.body, /<title[\s>]/i, 'homepage has no <title>');
assert.doesNotMatch(res.body, FATAL, 'homepage shows a PHP error');
});

test('login form is present with its stable field names', async () => {
const res = await get('/wp-login.php');
assert.equal(res.status, 200, `expected 200 from /wp-login.php, got ${res.status}`);
assert.match(res.body, /name=["']log["']/, 'no username field (name="log")');
assert.match(res.body, /name=["']pwd["']/, 'no password field (name="pwd")');
});

// The ?rest_route= form works whatever the permalink setting is; /wp-json/ only
// resolves with pretty permalinks, which a fresh install doesn't have.
test('REST API root advertises the wp/v2 namespace', async () => {
const res = await get('/?rest_route=/');
assert.equal(res.status, 200, `expected 200 from the REST root, got ${res.status}`);

const body = JSON.parse(res.body);
assert.ok(Array.isArray(body.namespaces), 'REST root has no namespaces array');
assert.ok(body.namespaces.includes('wp/v2'), 'REST root does not advertise wp/v2');
});

test('REST posts endpoint returns a collection', async () => {
const res = await get('/?rest_route=/wp/v2/posts');
assert.equal(res.status, 200, `expected 200 from the posts endpoint, got ${res.status}`);
assert.ok(Array.isArray(JSON.parse(res.body)), 'posts endpoint did not return an array');
});

// ?p= with a missing id is a 404 on any permalink setting; an unknown pretty
// path is not (WordPress canonical-redirects it on plain permalinks).
test('a missing post returns 404, not a server error', async () => {
const res = await get('/?p=999999999');
assert.equal(res.status, 404, `expected 404 for a missing post, got ${res.status}`);
});
Loading
Loading