Skip to content
Open
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 @@ -21,6 +21,12 @@ jobs:
run: node --test --test-concurrency=1 storage.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
98 changes: 87 additions & 11 deletions .github/workflows/update-wp.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,101 @@
# Opens a pull request when a new WordPress release comes out.
#
# This runs in every copy of the repository, not just upstream: a site deployed
# from here is a repository nobody logs into, and DISALLOW_FILE_MODS means
# wp-admin will never offer the update either. The pull request is how a site
# owner finds out and how they review the change before it deploys.
#
# Two things have to be switched on by hand in a copy, and neither can be set
# from this file -- see "Keeping WordPress updated" in the readme:
# - Settings > Actions > General > "Allow GitHub Actions to create and
# approve pull requests"
# - a scheduled workflow is disabled automatically after 60 days without a
# push, which is the normal state of a site repository
name: Update WordPress
on:
schedule:
- cron: "0 0 * * *"
# 21:10 UTC. Core release parties start at 15:00 or 17:00 UTC depending on
# the cycle and the package ships an hour or two in, so this picks up a
# release the same day rather than the next one. Cron here is always UTC, so
# the time does not shift with daylight saving. The odd minute is deliberate:
# GitHub queues everything scheduled on the hour together and drops runs when
# that queue is full.
- cron: "10 21 * * *"
# A release that slips past the evening run, or an evening run GitHub drops,
# is picked up twelve hours later instead of twenty-four.
- cron: "10 9 * * *"
workflow_dispatch:

# Repositories created after February 2023 give GITHUB_TOKEN read-only access
# by default. The deploy buttons create a brand new repository, so this applies
# to every copy, and without it the branch push fails in all of them.
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 is the pull
# request body rather than something only visible in the run log.
- 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'

# A separate job on its own branch, so a plugin update is never mixed into a
# core one. Either can be merged, closed or left sitting without affecting
# the other, and a plugin that breaks a site is one revert rather than two.
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 can only be reported on: wordpress.org publishes no checksums
# for them, so an untouched theme can't be told from an edited one. With
# nothing to commit there is no pull request to carry the findings, so
# they 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 @@ -144,6 +144,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
130 changes: 130 additions & 0 deletions test/wpUpdate.apply.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// The file operations in util/wp-update/files.js, against real directories.
//
// The plan tests cover which files get touched; these cover what touching them
// does on disk -- that a write lands, a delete removes only its own file, and
// that a directory holding anything else survives. Core and plugin updates share
// these, so a plugin update is subject to the same rules as a core one.

const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');

const updater = require('../util/wp-update/files.js');
const core = require('../util/wp-update/core.js');

let workDir;
let wpRoot;
let releaseRoot;

test.beforeEach(() => {
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-update-test-'));
wpRoot = path.join(workDir, 'wp');
releaseRoot = path.join(workDir, 'release');
fs.mkdirSync(wpRoot, { recursive: true });
fs.mkdirSync(releaseRoot, { recursive: true });
});

test.afterEach(() => {
fs.rmSync(workDir, { recursive: true, force: true });
});

function write(root, filePath, contents) {
const file = path.join(root, filePath);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
}

function read(filePath) {
return fs.readFileSync(path.join(wpRoot, filePath), 'utf8');
}

function exists(filePath) {
return fs.existsSync(path.join(wpRoot, filePath));
}

test('a write creates missing directories and overwrites the old file', () => {
write(wpRoot, 'wp-login.php', 'old');
write(releaseRoot, 'wp-login.php', 'new');
write(releaseRoot, 'wp-includes/blocks/new.php', 'added');

updater.apply(wpRoot, releaseRoot, {
writes: ['wp-login.php', 'wp-includes/blocks/new.php'],
deletes: [],
});

assert.strictEqual(read('wp-login.php'), 'new');
assert.strictEqual(read('wp-includes/blocks/new.php'), 'added');
});

test('a delete removes the file and prunes the directories it emptied', () => {
write(wpRoot, 'wp-includes/old/deep/gone.php', 'x');

updater.apply(wpRoot, releaseRoot, { writes: [], deletes: ['wp-includes/old/deep/gone.php'] });

assert.ok(!exists('wp-includes/old/deep/gone.php'));
assert.ok(!exists('wp-includes/old'));
// Pruning stops at the WordPress root even when everything under it went.
assert.ok(fs.existsSync(wpRoot));
});

// The reason nothing here uses rsync --delete: a deleted core file must not
// take a sibling the owner added with it.
test('a delete leaves a file the owner added in the same directory', () => {
write(wpRoot, 'wp-content/plugins/akismet/akismet.php', 'x');
write(wpRoot, 'wp-content/plugins/akismet/my-notes.txt', 'mine');

updater.apply(wpRoot, releaseRoot, {
writes: [],
deletes: ['wp-content/plugins/akismet/akismet.php'],
});

assert.ok(!exists('wp-content/plugins/akismet/akismet.php'));
assert.strictEqual(read('wp-content/plugins/akismet/my-notes.txt'), 'mine');
});

test('hashing covers only the paths asked for, and skips what is not there', () => {
write(wpRoot, 'wp-login.php', 'x');
write(wpRoot, 'wp-config.php', 'secrets');

const disk = updater.hashDisk(wpRoot, ['wp-login.php', 'wp-settings.php']);

// 9dd4e461268c8034f5c8564e155c67a6 is md5('x').
assert.deepStrictEqual(disk, { 'wp-login.php': '9dd4e461268c8034f5c8564e155c67a6' });
});

// A directory sitting where WordPress ships a file has no md5, and must not
// read as "unmodified core file" and get deleted.
test('a directory where a file is expected is never mistaken for that file', () => {
fs.mkdirSync(path.join(wpRoot, 'wp-login.php'));

const disk = updater.hashDisk(wpRoot, ['wp-login.php']);

assert.strictEqual(disk['wp-login.php'], 'not-a-file');
});

test('the installed version comes from wp-includes/version.php', () => {
write(wpRoot, 'wp-includes/version.php', "<?php\n$wp_version = '7.0.2';\n");

assert.strictEqual(core.installedVersion(wpRoot), '7.0.2');
});

test('ignored paths are found through git', () => {
execFileSync('git', ['init', '-q'], { cwd: workDir });
write(workDir, '.gitignore', 'package-lock.json\n');
write(wpRoot, 'wp-content/themes/twentytwentyfive/package-lock.json', '{}');
write(wpRoot, 'wp-login.php', 'x');

const ignored = updater.ignoredPaths(wpRoot, [
'wp-content/themes/twentytwentyfive/package-lock.json',
'wp-login.php',
]);

assert.deepStrictEqual([...ignored], ['wp-content/themes/twentytwentyfive/package-lock.json']);
});

test('nothing is ignored when the copy is not a git repository', () => {
assert.deepStrictEqual([...updater.ignoredPaths(wpRoot, ['wp-login.php'])], []);
});
Loading
Loading