Test against the newest WordPress and WooCommerce, and ship updates
Three things this repository could not do: prove the plugin still works on a
WordPress or WooCommerce released after it, get a fix onto a shop without
someone uploading a zip, and do either of those for the next plugin without
starting over.
ci/ is the test harness. ci/site-test.sh builds a throwaway WordPress +
WooCommerce in containers at any versions you name — including `nightly` and
`beta` — installs exactly the files the release zip will contain, and runs
tests/smoke.php inside it with wp eval-file. Real cart, real coupons, real
WC_Shipping_Rate objects: a mocked cart keeps passing after WooCommerce has
changed underneath it, which is the one failure worth catching here. WP_DEBUG
is on and any notice logged from this plugin's directory fails the build,
because that is the shape an upgrade actually breaks in — nothing fails, a
notice appears, and two releases later it is a fatal.
compat.yml runs that weekly against today's releases, the next WordPress and
the next WooCommerce, the oldest PHP the header promises and the newest PHP.
When it is green it opens a PR bumping "Tested up to" and "WC tested up to" —
only when green, because WooCommerce shows that second value to the shop owner
as an untested-plugin banner, and that banner is what they read before deciding
whether to press update.
includes/class-updater.php is the distribution half. It hooks
update_plugins_{hostname} for the host in the new "Update URI" header and reads
the Gitea release API, so WordPress handles the notice, the download, the
"Enable auto-updates" link and wp plugin update with no further code. Update
URI earns its place twice: since 5.8 a plugin that declares one is never asked
about on wordpress.org, so a .org plugin sharing this slug cannot be pushed
onto a shop running this one. The check sends nothing about the site and
overrides WordPress's default user agent, which carries the site's own URL.
release.yml tags, tests, builds, publishes — then reads the feed back with no
credentials. A private repository and a release with no zip attached both leave
a page that looks perfect to anyone logged in and an update no shop is ever
offered.
Measured before committing, all green: WP 7.1 + WC 11.1 on PHP 8.3 and 7.4,
WP 7.2-alpha on 8.3, and the beta cell falling back to stable on 8.4. No
deprecations from this plugin on any of them.
Three guards are there because the bug they catch is invisible without them:
the annotated tag is re-fetched before its message is read (checkout turns it
into a lightweight tag pointing at the merge commit), the two workflow roots
are kept in sync (.gitea/workflows hides .github/workflows entirely), and every
file the code loads must be in the release zip — which caught class-updater.php
being referenced and uncommitted while every local test passed.
docs/REUSE.md is the port to the next plugin: copy ci/, rewrite five lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e4a40e53a0
commit
eca2851d35
+23
@@ -0,0 +1,23 @@
|
||||
# What must NOT end up in the installed plugin. ci/dist-files.sh reads this,
|
||||
# and both the release zip and the "is every shipped file guarded" check are
|
||||
# built from that one list — so a file excluded here is excluded everywhere.
|
||||
#
|
||||
# Anything left in wp-content/plugins/ can in principle be fetched over HTTP,
|
||||
# which is why internal notes and tooling are kept out rather than merely
|
||||
# ignored.
|
||||
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.gitignore
|
||||
.distignore
|
||||
.DS_Store
|
||||
._*
|
||||
.idea
|
||||
.vscode
|
||||
build
|
||||
|
||||
# The test and release machinery. It runs on a runner, never on a shop.
|
||||
ci
|
||||
tests
|
||||
docs
|
||||
@@ -0,0 +1,25 @@
|
||||
# Why this directory exists, and the trap it carries
|
||||
|
||||
Gitea uses `.gitea/workflows` **if it exists**, and falls back to
|
||||
`.github/workflows` only when it does not. It never merges them.
|
||||
|
||||
So adding one Gitea-only workflow here silently switches off CI — and therefore
|
||||
auto-merge — for the whole repository on Gitea. There is no error and no run to
|
||||
click on. It looks exactly like a runner that has not picked the job up, so you
|
||||
wait, and waiting never fixes it.
|
||||
|
||||
**Every workflow that must run on Gitea has to be in this directory.**
|
||||
`ci.yml` and `compat.yml` are kept byte-identical in both roots, and
|
||||
`ci/workflows-in-sync.sh` fails if the two copies drift apart or if a workflow
|
||||
appears in only one of them without a stated reason.
|
||||
|
||||
`release.yml` is the deliberate exception: a release workflow publishes to
|
||||
whichever forge runs it, and the two copies publish to different places. The
|
||||
Gitea one is the one that matters — that is where the update feed shops read
|
||||
lives.
|
||||
|
||||
Two further rules learned the hard way:
|
||||
|
||||
- **No `concurrency:` block in a Gitea workflow.** It produces no run at all —
|
||||
not a failed one, not a cancelled one. Same shape of trap, same symptom.
|
||||
- If a Gitea workflow produces no run, **suspect the file, not the runner**.
|
||||
@@ -0,0 +1,58 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# No `concurrency:` block here, and that is not an oversight. Measured on Gitea
|
||||
# 1.27.2, adding one produces no run at all — not a failed run, not a cancelled
|
||||
# one: nothing to click on, which looks exactly like a runner that has not
|
||||
# picked the job up, so you wait, and waiting never fixes it.
|
||||
#
|
||||
# This file is kept byte-identical in .github/workflows and .gitea/workflows.
|
||||
# ci/workflows-in-sync.sh fails if the two drift apart, because a workflow that
|
||||
# lives only in .github/ does not run on Gitea at all.
|
||||
|
||||
jobs:
|
||||
static:
|
||||
name: Static checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Workflows in sync
|
||||
run: ci/workflows-in-sync.sh
|
||||
|
||||
- name: Lint, hygiene, versions and compatibility headers
|
||||
run: ci/static.sh
|
||||
|
||||
- name: The release zip is buildable and complete
|
||||
run: ci/build-zip.sh
|
||||
|
||||
site:
|
||||
name: WordPress ${{ matrix.wp }} + WooCommerce ${{ matrix.wc }} on PHP ${{ matrix.php }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# One cell per push: what a shop that keeps up to date is running today.
|
||||
# The wider matrix — the oldest PHP the header promises, the next
|
||||
# WordPress, the next WooCommerce — runs weekly in compat.yml, so a single
|
||||
# self-hosted runner is not asked to do all of it on every commit.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { wp: latest, wc: latest, php: '8.3' }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install into a throwaway shop and run the tests
|
||||
env:
|
||||
WP_VERSION: ${{ matrix.wp }}
|
||||
WC_VERSION: ${{ matrix.wc }}
|
||||
PHP_VERSION: ${{ matrix.php }}
|
||||
run: ci/site-test.sh
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Compatibility
|
||||
|
||||
# The question this answers: *will the shop still work after I press update?*
|
||||
#
|
||||
# It runs on a schedule rather than on a commit, because the thing that breaks
|
||||
# a finished plugin is not a new commit — it is a new WordPress or a new
|
||||
# WooCommerce, released on somebody else's timetable. A weekly run turns that
|
||||
# into a red build here, days before it turns into a broken checkout there.
|
||||
#
|
||||
# `nightly` and `beta` are the early warning: they are what the next release
|
||||
# will be. When one of those two cells goes red and the others stay green, the
|
||||
# plugin has a few weeks to be fixed rather than a few hours.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Monday morning, so a failure has a whole week in front of it.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
# Also whenever the tooling itself changes, because a compatibility run that
|
||||
# silently stopped testing anything is worse than no run at all.
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ci/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/compat.yml'
|
||||
- '.gitea/workflows/compat.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
# No `concurrency:` block — see the note in ci.yml. Kept byte-identical in
|
||||
# .github/workflows and .gitea/workflows; ci/workflows-in-sync.sh enforces it.
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
name: WP ${{ matrix.wp }} / WC ${{ matrix.wc }} / PHP ${{ matrix.php }}
|
||||
# The schedule fires on both forges, and the second run would be the same
|
||||
# work again on a single self-hosted runner for no extra information.
|
||||
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
||||
# so the run is still one click away there.
|
||||
if: github.event_name != 'schedule' || github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Today's shop.
|
||||
- { wp: latest, wc: latest, php: '8.3' }
|
||||
# The next WordPress, before it ships.
|
||||
- { wp: nightly, wc: latest, php: '8.3' }
|
||||
# The next WooCommerce, before it ships. Falls back to the stable
|
||||
# release when there is no open beta, so the cell is never skipped.
|
||||
- { wp: latest, wc: beta, php: '8.3' }
|
||||
# The floor the plugin header promises. A shop on an old host is
|
||||
# exactly the shop that will not notice a fatal quickly.
|
||||
- { wp: latest, wc: latest, php: '7.4' }
|
||||
# And the newest PHP, which is where deprecations show up first.
|
||||
- { wp: latest, wc: latest, php: '8.4' }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install into a throwaway shop and run the tests
|
||||
env:
|
||||
WP_VERSION: ${{ matrix.wp }}
|
||||
WC_VERSION: ${{ matrix.wc }}
|
||||
PHP_VERSION: ${{ matrix.php }}
|
||||
run: ci/site-test.sh
|
||||
|
||||
headers:
|
||||
name: Keep the compatibility headers honest
|
||||
# Only after the current releases have actually been tested. Bumping
|
||||
# "Tested up to" on a red build would be writing a claim we have just
|
||||
# watched fail.
|
||||
needs: matrix
|
||||
# The PR is opened through the GitHub API, and the scheduled run that
|
||||
# produces it is the GitHub one.
|
||||
if: github.server_url == 'https://github.com' && github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Bump the headers and open a PR if they are behind
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ci/bump-tested-up-to.sh
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Release
|
||||
|
||||
# Builds the plugin zip, proves it installs and works, and publishes it here.
|
||||
#
|
||||
# This copy is the one that matters: Gitea is where the update feed lives, so
|
||||
# this is the run that decides whether shops are offered the new version. The
|
||||
# .github copy does the same work and publishes to GitHub — deliberately not
|
||||
# byte-identical, and ci/workflows-in-sync.sh knows that.
|
||||
#
|
||||
# Nothing is published before the tests have passed against the current
|
||||
# WordPress and WooCommerce. A release that fails here is one that was never
|
||||
# offered to anybody, which is the entire point of doing it in this order.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish, e.g. v1.4.0'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Which tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
[ -n "$TAG" ] || { echo "Could not determine the tag."; exit 1; }
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
# ci/publish-release.sh re-fetches the tag as a ref before reading
|
||||
# its message, because checkout overwrites that ref with the commit
|
||||
# SHA and turns the annotated tag into a lightweight one. It needs
|
||||
# the history to do that.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: The header version is the tag
|
||||
run: |
|
||||
HEADER=$(ci/version.sh)
|
||||
TAG_VERSION="${{ steps.tag.outputs.version }}"
|
||||
echo "plugin header: $HEADER"
|
||||
echo "tag: $TAG_VERSION"
|
||||
if [ "$HEADER" != "$TAG_VERSION" ]; then
|
||||
echo "::error::WordPress compares the update feed against the plugin header and nothing else."
|
||||
echo "::error::With the header at $HEADER and the tag at $TAG_VERSION, every shop that installs this update is offered it again immediately, forever."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Static checks
|
||||
run: ci/static.sh
|
||||
|
||||
- name: It installs and works on the current WordPress and WooCommerce
|
||||
env:
|
||||
WP_VERSION: latest
|
||||
WC_VERSION: latest
|
||||
PHP_VERSION: '8.3'
|
||||
run: ci/site-test.sh
|
||||
|
||||
- name: Build the zip
|
||||
run: ci/build-zip.sh
|
||||
|
||||
- name: Publish, then read the feed back anonymously
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ci/publish-release.sh
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Auto-merge
|
||||
|
||||
# Merges a pull request once its CI run passes, so the owner does not have to
|
||||
# click merge on every change. Safety rails:
|
||||
# - only when the CI workflow concluded "success";
|
||||
# - only for PRs opened by the repo owner, never an external contributor;
|
||||
# - skipped on any PR labelled "hold" or "no-automerge" — apply the label at
|
||||
# PR creation, because otherwise the merge may happen before you can.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The server check keeps this off Gitea, which mirrors this repo. There the
|
||||
# gh calls have no GitHub to talk to, so every push to main would end in a
|
||||
# failed "merge" run and a red mark on a branch that is perfectly fine.
|
||||
if: >
|
||||
github.server_url == 'https://github.com' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the PR whose CI just passed
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
pr=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open \
|
||||
--json number,author,labels --limit 1)
|
||||
num=$(echo "$pr" | jq -r '.[0].number // empty')
|
||||
if [ -z "$num" ]; then
|
||||
echo "No open PR for branch '$BRANCH' — nothing to merge."
|
||||
exit 0
|
||||
fi
|
||||
author=$(echo "$pr" | jq -r '.[0].author.login')
|
||||
owner="${REPO%%/*}"
|
||||
if [ "$author" != "$owner" ]; then
|
||||
echo "PR #$num is by '$author' (not the owner '$owner') — leaving it for manual review."
|
||||
exit 0
|
||||
fi
|
||||
if echo "$pr" | jq -r '.[0].labels[].name' | grep -qiE '^(hold|no-automerge)$'; then
|
||||
echo "PR #$num carries a hold/no-automerge label — skipping auto-merge."
|
||||
exit 0
|
||||
fi
|
||||
echo "CI is green — auto-merging PR #$num."
|
||||
gh pr merge "$num" --repo "$REPO" --merge --delete-branch
|
||||
@@ -0,0 +1,58 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# No `concurrency:` block here, and that is not an oversight. Measured on Gitea
|
||||
# 1.27.2, adding one produces no run at all — not a failed run, not a cancelled
|
||||
# one: nothing to click on, which looks exactly like a runner that has not
|
||||
# picked the job up, so you wait, and waiting never fixes it.
|
||||
#
|
||||
# This file is kept byte-identical in .github/workflows and .gitea/workflows.
|
||||
# ci/workflows-in-sync.sh fails if the two drift apart, because a workflow that
|
||||
# lives only in .github/ does not run on Gitea at all.
|
||||
|
||||
jobs:
|
||||
static:
|
||||
name: Static checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Workflows in sync
|
||||
run: ci/workflows-in-sync.sh
|
||||
|
||||
- name: Lint, hygiene, versions and compatibility headers
|
||||
run: ci/static.sh
|
||||
|
||||
- name: The release zip is buildable and complete
|
||||
run: ci/build-zip.sh
|
||||
|
||||
site:
|
||||
name: WordPress ${{ matrix.wp }} + WooCommerce ${{ matrix.wc }} on PHP ${{ matrix.php }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# One cell per push: what a shop that keeps up to date is running today.
|
||||
# The wider matrix — the oldest PHP the header promises, the next
|
||||
# WordPress, the next WooCommerce — runs weekly in compat.yml, so a single
|
||||
# self-hosted runner is not asked to do all of it on every commit.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { wp: latest, wc: latest, php: '8.3' }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install into a throwaway shop and run the tests
|
||||
env:
|
||||
WP_VERSION: ${{ matrix.wp }}
|
||||
WC_VERSION: ${{ matrix.wc }}
|
||||
PHP_VERSION: ${{ matrix.php }}
|
||||
run: ci/site-test.sh
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Compatibility
|
||||
|
||||
# The question this answers: *will the shop still work after I press update?*
|
||||
#
|
||||
# It runs on a schedule rather than on a commit, because the thing that breaks
|
||||
# a finished plugin is not a new commit — it is a new WordPress or a new
|
||||
# WooCommerce, released on somebody else's timetable. A weekly run turns that
|
||||
# into a red build here, days before it turns into a broken checkout there.
|
||||
#
|
||||
# `nightly` and `beta` are the early warning: they are what the next release
|
||||
# will be. When one of those two cells goes red and the others stay green, the
|
||||
# plugin has a few weeks to be fixed rather than a few hours.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Monday morning, so a failure has a whole week in front of it.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
# Also whenever the tooling itself changes, because a compatibility run that
|
||||
# silently stopped testing anything is worse than no run at all.
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ci/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/compat.yml'
|
||||
- '.gitea/workflows/compat.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
# No `concurrency:` block — see the note in ci.yml. Kept byte-identical in
|
||||
# .github/workflows and .gitea/workflows; ci/workflows-in-sync.sh enforces it.
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
name: WP ${{ matrix.wp }} / WC ${{ matrix.wc }} / PHP ${{ matrix.php }}
|
||||
# The schedule fires on both forges, and the second run would be the same
|
||||
# work again on a single self-hosted runner for no extra information.
|
||||
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
||||
# so the run is still one click away there.
|
||||
if: github.event_name != 'schedule' || github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Today's shop.
|
||||
- { wp: latest, wc: latest, php: '8.3' }
|
||||
# The next WordPress, before it ships.
|
||||
- { wp: nightly, wc: latest, php: '8.3' }
|
||||
# The next WooCommerce, before it ships. Falls back to the stable
|
||||
# release when there is no open beta, so the cell is never skipped.
|
||||
- { wp: latest, wc: beta, php: '8.3' }
|
||||
# The floor the plugin header promises. A shop on an old host is
|
||||
# exactly the shop that will not notice a fatal quickly.
|
||||
- { wp: latest, wc: latest, php: '7.4' }
|
||||
# And the newest PHP, which is where deprecations show up first.
|
||||
- { wp: latest, wc: latest, php: '8.4' }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install into a throwaway shop and run the tests
|
||||
env:
|
||||
WP_VERSION: ${{ matrix.wp }}
|
||||
WC_VERSION: ${{ matrix.wc }}
|
||||
PHP_VERSION: ${{ matrix.php }}
|
||||
run: ci/site-test.sh
|
||||
|
||||
headers:
|
||||
name: Keep the compatibility headers honest
|
||||
# Only after the current releases have actually been tested. Bumping
|
||||
# "Tested up to" on a red build would be writing a claim we have just
|
||||
# watched fail.
|
||||
needs: matrix
|
||||
# The PR is opened through the GitHub API, and the scheduled run that
|
||||
# produces it is the GitHub one.
|
||||
if: github.server_url == 'https://github.com' && github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Bump the headers and open a PR if they are behind
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ci/bump-tested-up-to.sh
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Release
|
||||
|
||||
# Builds the plugin zip, proves it installs and works, and publishes it here.
|
||||
#
|
||||
# This copy publishes to GitHub. The update feed shops read lives on Gitea, so
|
||||
# the .gitea copy is the one that decides whether shops are offered the new
|
||||
# version; this one keeps GitHub's releases in step. Deliberately not
|
||||
# byte-identical, and ci/workflows-in-sync.sh knows that.
|
||||
#
|
||||
# Nothing is published before the tests have passed against the current
|
||||
# WordPress and WooCommerce. A release that fails here is one that was never
|
||||
# offered to anybody, which is the entire point of doing it in this order.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish, e.g. v1.4.0'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# A release workflow publishes to whichever forge RUNS it. Gitea picks up
|
||||
# .github/workflows only when .gitea/workflows does not exist — so without
|
||||
# this gate, deleting that directory would quietly start publishing a
|
||||
# second, differently-built release to Gitea on every tag, while GitHub
|
||||
# looked fine.
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Which tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
[ -n "$TAG" ] || { echo "Could not determine the tag."; exit 1; }
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
# ci/publish-release.sh re-fetches the tag as a ref before reading
|
||||
# its message, because checkout overwrites that ref with the commit
|
||||
# SHA and turns the annotated tag into a lightweight one. It needs
|
||||
# the history to do that.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: The header version is the tag
|
||||
run: |
|
||||
HEADER=$(ci/version.sh)
|
||||
TAG_VERSION="${{ steps.tag.outputs.version }}"
|
||||
echo "plugin header: $HEADER"
|
||||
echo "tag: $TAG_VERSION"
|
||||
if [ "$HEADER" != "$TAG_VERSION" ]; then
|
||||
echo "::error::WordPress compares the update feed against the plugin header and nothing else."
|
||||
echo "::error::With the header at $HEADER and the tag at $TAG_VERSION, every shop that installs this update is offered it again immediately, forever."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Static checks
|
||||
run: ci/static.sh
|
||||
|
||||
- name: It installs and works on the current WordPress and WooCommerce
|
||||
env:
|
||||
WP_VERSION: latest
|
||||
WC_VERSION: latest
|
||||
PHP_VERSION: '8.3'
|
||||
run: ci/site-test.sh
|
||||
|
||||
- name: Build the zip
|
||||
run: ci/build-zip.sh
|
||||
|
||||
- name: Publish, then read the feed back anonymously
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ci/publish-release.sh
|
||||
@@ -3,3 +3,4 @@ node_modules/
|
||||
vendor/
|
||||
*.zip
|
||||
wc-credentials.json
|
||||
build/
|
||||
|
||||
+9
-2
@@ -2,9 +2,11 @@
|
||||
Contributors: wedsmp
|
||||
Tags: woocommerce, discount, email, shipping, coupon
|
||||
Requires at least: 5.8
|
||||
Tested up to: 6.7
|
||||
Tested up to: 7.1
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.3.1
|
||||
Stable tag: 1.4.0
|
||||
WC requires at least: 6.0
|
||||
WC tested up to: 11.1.0
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
@@ -94,6 +96,11 @@ The most specific match wins: exact email, then domain, then partial match.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.4.0 =
|
||||
* The plugin now updates itself. WordPress checks the plugin's own release feed and offers new versions in Plugins and Dashboard → Updates, so an update no longer means uploading a zip by hand. The per-plugin "Enable auto-updates" link works too.
|
||||
* Tested against WordPress 7.1 and WooCommerce 11.1, and against the next release of each, so an update to either is checked before it reaches a shop rather than after.
|
||||
* Nothing about the site is sent when it checks for updates. The request is an unauthenticated read of a public file, and the user agent is overridden because WordPress's default one carries the site's own address.
|
||||
|
||||
= 1.3.1 =
|
||||
* Hardened loading: a class file that cannot be read is now recorded and reported in an admin notice instead of causing a fatal error. During a plugin upgrade WordPress replaces files one at a time, so a request landing mid-copy could previously take down wp-admin.
|
||||
* Admin tabs are only shown when their controller loaded, so an incomplete install costs a tab rather than the whole screen.
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the installable plugin zip and check it is actually installable.
|
||||
#
|
||||
# Writes build/<slug>.zip, containing a single top-level <slug>/ directory —
|
||||
# WordPress identifies an installed plugin by that folder name, so a zip that
|
||||
# unpacks to anything else installs a second copy alongside the first instead
|
||||
# of updating it.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
version=$(ci_plugin_version)
|
||||
[ -n "$version" ] || { echo "Could not read Version: from $PLUGIN_MAIN." >&2; exit 1; }
|
||||
|
||||
rm -rf build
|
||||
mkdir -p "build/$PLUGIN_SLUG"
|
||||
|
||||
# One list, shared with the hygiene checks and the test site, so a file cannot
|
||||
# be excluded from the tests and included in the release.
|
||||
./ci/dist-files.sh | while IFS= read -r f; do
|
||||
mkdir -p "build/$PLUGIN_SLUG/$(dirname "$f")"
|
||||
cp "$f" "build/$PLUGIN_SLUG/$f"
|
||||
done
|
||||
|
||||
# `zip` is on GitHub's runners but not in every Gitea runner image, and a
|
||||
# release that fails on the packaging step is the most annoying possible place
|
||||
# to discover a missing tool. python3 is present in both.
|
||||
(
|
||||
cd build
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
zip -rq "$PLUGIN_SLUG.zip" "$PLUGIN_SLUG"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python3 -m zipfile -c "$PLUGIN_SLUG.zip" "$PLUGIN_SLUG"
|
||||
else
|
||||
echo "Neither zip nor python3 is available to build the archive." >&2
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
|
||||
# Listing entries by name rather than parsing `unzip -l` output, with the same
|
||||
# python3 fallback as above because unzip is not guaranteed on a runner either.
|
||||
zip_entries() {
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -Z1 "build/$PLUGIN_SLUG.zip"
|
||||
else
|
||||
python3 -c 'import sys,zipfile;print("\n".join(zipfile.ZipFile(sys.argv[1]).namelist()))' \
|
||||
"build/$PLUGIN_SLUG.zip"
|
||||
fi
|
||||
}
|
||||
|
||||
ci_step "what the zip contains"
|
||||
zip_entries | sed 's/^/ /'
|
||||
|
||||
ci_step "checks"
|
||||
zip_entries | grep -qx "$PLUGIN_SLUG/$PLUGIN_MAIN" \
|
||||
|| { echo "The main plugin file is not in the zip." >&2; exit 1; }
|
||||
echo " the main file is present"
|
||||
|
||||
# A leaked ci/ or tests/ directory is not merely untidy: everything under
|
||||
# wp-content/plugins/ can in principle be fetched over HTTP from a live shop.
|
||||
for unwanted in ci tests docs .git .github .gitea; do
|
||||
if zip_entries | grep -q "^$PLUGIN_SLUG/$unwanted/"; then
|
||||
echo "The zip contains $unwanted/, which must never reach a shop." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo " no tooling, tests or git files leaked in"
|
||||
|
||||
# Every entry sits under exactly one top-level directory named for the slug.
|
||||
strays=$(zip_entries | grep -v "^$PLUGIN_SLUG/" || true)
|
||||
[ -z "$strays" ] || { echo "Files outside $PLUGIN_SLUG/ in the zip:" >&2; echo "$strays" >&2; exit 1; }
|
||||
echo " everything unpacks into $PLUGIN_SLUG/"
|
||||
|
||||
size=$(du -h "build/$PLUGIN_SLUG.zip" | cut -f1)
|
||||
echo
|
||||
echo "build/$PLUGIN_SLUG.zip — version $version, $size"
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update "Tested up to" and "WC tested up to" to the versions that were just
|
||||
# tested, and open a pull request if anything changed.
|
||||
#
|
||||
# Run only after the compatibility matrix has gone green against the current
|
||||
# releases. The headers are a claim made to whoever installs the plugin — and
|
||||
# in WooCommerce's case a banner the shop owner reads before deciding whether
|
||||
# to update at all — so bumping them on a red build would be publishing a claim
|
||||
# we have just watched fail.
|
||||
#
|
||||
# Writes nothing when the headers are already current, so a quiet week costs a
|
||||
# green run and no noise.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
current_wp=$(curl -fsS --max-time 20 'https://api.wordpress.org/core/stable-check/1.0/' \
|
||||
| tr ',' '\n' | sed -n -E 's/.*"([0-9][^"]*)"[[:space:]]*:[[:space:]]*"latest".*/\1/p' | head -1)
|
||||
current_wc=$(curl -fsS --max-time 20 'https://api.wordpress.org/plugins/info/1.0/woocommerce.json' \
|
||||
| sed -n -E 's/.*"version":"([^"]+)".*/\1/p' | head -1)
|
||||
|
||||
[ -n "$current_wp" ] || { echo "Could not read the current WordPress version." >&2; exit 1; }
|
||||
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
|
||||
changed=()
|
||||
|
||||
if [ -n "$readme" ]; then
|
||||
tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||
if [ "$tested_wp" != "$current_wp" ]; then
|
||||
sed -i -E "s/^Tested up to:.*/Tested up to: $current_wp/" "$readme"
|
||||
changed+=("WordPress ${tested_wp:-?} → $current_wp")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$NEEDS_WOOCOMMERCE" = yes ] && [ -n "$current_wc" ]; then
|
||||
tested_wc=$(ci_plugin_header 'WC tested up to')
|
||||
if [ "$tested_wc" != "$current_wc" ]; then
|
||||
sed -i -E "s/^([[:space:]]*\*[[:space:]]*WC tested up to:).*/\1 $current_wc/" "$PLUGIN_MAIN"
|
||||
[ -n "$readme" ] && sed -i -E "s/^WC tested up to:.*/WC tested up to: $current_wc/" "$readme"
|
||||
changed+=("WooCommerce ${tested_wc:-?} → $current_wc")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ${#changed[@]} -eq 0 ]; then
|
||||
echo "The compatibility headers are already current — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-read from disk rather than trusting the sed: a pattern that matched
|
||||
# nothing would otherwise produce an empty, cheerful pull request.
|
||||
for f in "$PLUGIN_MAIN" ${readme:+"$readme"}; do
|
||||
git diff --quiet -- "$f" && continue
|
||||
echo "--- $f"
|
||||
git diff -- "$f" | sed -n '/^[+-][^+-]/p'
|
||||
done
|
||||
git diff --quiet && { echo "Nothing actually changed on disk — check the sed patterns." >&2; exit 1; }
|
||||
|
||||
branch="compat/tested-up-to-$current_wp-wc-$current_wc"
|
||||
git config user.name "compatibility check"
|
||||
git config user.email "noreply@localhost"
|
||||
git checkout -b "$branch"
|
||||
git commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
|
||||
|
||||
$(printf '%s\n' "${changed[@]}")
|
||||
|
||||
The compatibility matrix went green against these versions before this
|
||||
was written, so the headers now say what was actually tested. WooCommerce
|
||||
shows the 'WC tested up to' value to the shop owner as an untested-plugin
|
||||
banner, which is what they read before deciding whether to press update."
|
||||
|
||||
git push -u origin "$branch"
|
||||
|
||||
gh pr create \
|
||||
--title "Tested against WordPress $current_wp and WooCommerce $current_wc" \
|
||||
--body "$(printf 'The weekly compatibility run passed against the current releases, so the headers are updated to match:\n\n%s\n\nOpened automatically by `ci/bump-tested-up-to.sh`. The versions here were tested before this PR existed; if the matrix had failed, the job that opens this would not have run.\n' "$(printf -- '- %s\n' "${changed[@]}")")"
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Verify that every translation call carries the plugin's text domain.
|
||||
*
|
||||
* A mistyped or missing domain does not break anything visibly — the string
|
||||
* simply never gets translated, and nobody notices until the shop is asked to
|
||||
* run in another language.
|
||||
*
|
||||
* Tokenised rather than grepped, so a domain-looking string inside a sentence
|
||||
* cannot pass for the real thing.
|
||||
*/
|
||||
|
||||
// Copied between plugins, so there is no sensible default domain: a wrong one
|
||||
// would report "all good" while checking for somebody else's text domain.
|
||||
if ( empty( $argv[1] ) ) {
|
||||
fwrite( STDERR, "Usage: php ci/check-textdomain.php <text-domain> [root]\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$domain = $argv[1];
|
||||
$root = $argv[2] ?? '.';
|
||||
$functions = array(
|
||||
'__',
|
||||
'_e',
|
||||
'_x',
|
||||
'_ex',
|
||||
'_n',
|
||||
'_nx',
|
||||
'_n_noop',
|
||||
'_nx_noop',
|
||||
'esc_html__',
|
||||
'esc_html_e',
|
||||
'esc_html_x',
|
||||
'esc_attr__',
|
||||
'esc_attr_e',
|
||||
'esc_attr_x',
|
||||
);
|
||||
|
||||
$problems = 0;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveCallbackFilterIterator(
|
||||
new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ),
|
||||
static function ( $file ) {
|
||||
$name = $file->getFilename();
|
||||
return ! in_array( $name, array( '.git', 'node_modules', 'vendor', 'build' ), true );
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( 'php' !== strtolower( $file->getExtension() ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
$tokens = token_get_all( file_get_contents( $path ) );
|
||||
$count = count( $tokens );
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$token = $tokens[ $i ];
|
||||
|
||||
if ( ! is_array( $token ) || T_STRING !== $token[0] || ! in_array( $token[1], $functions, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip method calls and definitions: ->__(), function __().
|
||||
$prev = $i - 1;
|
||||
while ( $prev >= 0 && is_array( $tokens[ $prev ] ) && T_WHITESPACE === $tokens[ $prev ][0] ) {
|
||||
--$prev;
|
||||
}
|
||||
if ( $prev >= 0 && is_array( $tokens[ $prev ] ) && in_array( $tokens[ $prev ][0], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION ), true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the opening parenthesis.
|
||||
$j = $i + 1;
|
||||
while ( $j < $count && is_array( $tokens[ $j ] ) && T_WHITESPACE === $tokens[ $j ][0] ) {
|
||||
++$j;
|
||||
}
|
||||
if ( $j >= $count || '(' !== $tokens[ $j ] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect every literal string passed at the top level of this call.
|
||||
$depth = 0;
|
||||
$strings = array();
|
||||
|
||||
for ( $k = $j; $k < $count; $k++ ) {
|
||||
$t = $tokens[ $k ];
|
||||
|
||||
if ( '(' === $t ) {
|
||||
++$depth;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ')' === $t ) {
|
||||
--$depth;
|
||||
if ( 0 === $depth ) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( is_array( $t ) && T_CONSTANT_ENCAPSED_STRING === $t[0] ) {
|
||||
$strings[] = trim( $t[1], "'\"" );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! in_array( $domain, $strings, true ) ) {
|
||||
printf(
|
||||
"::error file=%s,line=%d::The %s() call is missing the text domain '%s'.\n",
|
||||
ltrim( str_replace( $root, '', $path ), '/' ),
|
||||
$token[2],
|
||||
$token[1],
|
||||
$domain
|
||||
);
|
||||
++$problems;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $problems > 0 ) {
|
||||
printf( "\n%d translation calls are missing the text domain.\n", $problems );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
echo "Every translation call carries the text domain '{$domain}'.\n";
|
||||
exit( 0 );
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# List the files that go into the installed plugin: everything git tracks,
|
||||
# minus the patterns in .distignore.
|
||||
#
|
||||
# Deliberately the only answer to "what ships". The release zip and the
|
||||
# hygiene checks both read it, so a file cannot be excluded from one and
|
||||
# included in the other — which is the version of this bug that ends with
|
||||
# internal notes readable over HTTP on a live shop.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[ -f .distignore ] || { echo ".distignore is missing — the zip would contain git files and internal notes." >&2; exit 1; }
|
||||
|
||||
patterns=()
|
||||
while IFS= read -r line; do
|
||||
line="${line%%#*}"
|
||||
line="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')"
|
||||
[ -n "$line" ] && patterns+=("${line%/}")
|
||||
done < .distignore
|
||||
|
||||
git ls-files | while IFS= read -r file; do
|
||||
keep=1
|
||||
for p in "${patterns[@]}"; do
|
||||
# A bare name excludes the file or directory wherever it appears, which is
|
||||
# how .distignore is read everywhere else. Anchor with a leading / to mean
|
||||
# the repo root only.
|
||||
case "$p" in
|
||||
/*) case "$file" in "${p#/}"|"${p#/}"/*) keep=0 ;; esac ;;
|
||||
*) case "$file" in $p|$p/*|*/$p|*/$p/*) keep=0 ;; esac ;;
|
||||
esac
|
||||
[ "$keep" = 0 ] && break
|
||||
done
|
||||
[ "$keep" = 1 ] && printf '%s\n' "$file"
|
||||
done
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/**
|
||||
* Print the newest published beta/RC of a wordpress.org plugin, or nothing.
|
||||
*
|
||||
* Usage: SLUG=woocommerce php ci/latest-prerelease.php
|
||||
*
|
||||
* The .org API lists every published version, prereleases included. The newest
|
||||
* one that is not a plain stable release is what a shop on the beta channel
|
||||
* would get, which makes it the earliest available warning that the next
|
||||
* release breaks us. A prerelease *older* than current stable is a leftover
|
||||
* from a finished cycle and is ignored — testing it would answer a question
|
||||
* nobody asked.
|
||||
*
|
||||
* @package ci
|
||||
*/
|
||||
|
||||
$slug = getenv( 'SLUG' );
|
||||
if ( ! $slug ) {
|
||||
fwrite( STDERR, "SLUG is not set.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$url = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information'
|
||||
. '&request[slug]=' . rawurlencode( $slug )
|
||||
. '&request[fields][versions]=1';
|
||||
|
||||
$body = @file_get_contents( $url ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
if ( false === $body ) {
|
||||
// A network blip must not fail the build — the caller falls back to the
|
||||
// stable release, which is still a useful test.
|
||||
fwrite( STDERR, "Could not reach the wordpress.org plugin API.\n" );
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
$info = json_decode( $body, true );
|
||||
$stable = isset( $info['version'] ) ? $info['version'] : '0';
|
||||
|
||||
$pre = array_filter(
|
||||
array_keys( isset( $info['versions'] ) ? $info['versions'] : array() ),
|
||||
static function ( $version ) {
|
||||
return (bool) preg_match( '/-(beta|rc)/i', $version );
|
||||
}
|
||||
);
|
||||
|
||||
usort( $pre, 'version_compare' );
|
||||
$newest = end( $pre );
|
||||
|
||||
if ( $newest && version_compare( $newest, $stable, '>' ) ) {
|
||||
echo $newest;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Shared helpers for the ci/ scripts. Sourced, never executed.
|
||||
#
|
||||
# Nothing in here is specific to one plugin — ci/plugin.conf holds that.
|
||||
|
||||
# shellcheck shell=bash
|
||||
|
||||
ci_repo_root() {
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
|
||||
}
|
||||
|
||||
# Load ci/plugin.conf and fail loudly on anything it forgot to set. A missing
|
||||
# value would otherwise turn into an empty path and a test that passes by
|
||||
# testing nothing.
|
||||
ci_load_conf() {
|
||||
local root
|
||||
root="$(ci_repo_root)"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$root/ci/plugin.conf"
|
||||
|
||||
local missing=
|
||||
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE; do
|
||||
[ -n "${!var:-}" ] || missing="$missing $var"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "ci/plugin.conf is missing:$missing" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$root/$PLUGIN_MAIN" ]; then
|
||||
echo "ci/plugin.conf points PLUGIN_MAIN at '$PLUGIN_MAIN', which does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# The version WordPress will compare against the update feed. Read from the
|
||||
# plugin header, which is the only copy WordPress ever looks at.
|
||||
ci_plugin_version() {
|
||||
sed -n -E 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' \
|
||||
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1
|
||||
}
|
||||
|
||||
# Read any other plugin header field, e.g. ci_plugin_header 'Requires at least'.
|
||||
ci_plugin_header() {
|
||||
sed -n -E "s/^[[:space:]]*\*?[[:space:]]*$1:[[:space:]]*(.*)/\1/p" \
|
||||
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1 | sed -E 's/[[:space:]]+$//'
|
||||
}
|
||||
|
||||
ci_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
|
||||
ci_fail() { printf '\033[31mFAIL\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# GitHub and Gitea both render these as annotations on the file and line.
|
||||
ci_annotate() { # level file line message
|
||||
printf '::%s file=%s,line=%s::%s\n' "$1" "$2" "$3" "$4"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# The only file under ci/ that knows anything about this particular plugin.
|
||||
#
|
||||
# Everything else in ci/ is plugin-agnostic on purpose: copying ci/, tests/ and
|
||||
# the two workflow roots into another plugin and rewriting these six lines is
|
||||
# the whole port. docs/REUSE.md walks through it.
|
||||
|
||||
# Directory name the plugin gets inside wp-content/plugins, and the name of the
|
||||
# built zip. WordPress identifies a plugin by "<slug>/<main file>", so changing
|
||||
# this after release orphans every installed copy.
|
||||
PLUGIN_SLUG=woocommerce-email-discount-manager
|
||||
|
||||
# The file carrying the plugin header. Version: in here is the version.
|
||||
PLUGIN_MAIN=woocommerce-email-discount-manager.php
|
||||
|
||||
# Every __() and esc_html__() call must use this domain or the translation is
|
||||
# silently dropped at runtime.
|
||||
TEXT_DOMAIN=wc-email-discount
|
||||
|
||||
# WooCommerce is installed into the test site before the plugin is activated.
|
||||
# Set to no for a plugin that does not need it, and the site comes up faster.
|
||||
NEEDS_WOOCOMMERCE=yes
|
||||
|
||||
# Run inside the live test site with `wp eval-file`, in this order. Fixtures
|
||||
# create the data; the smoke files assert against it. A non-zero exit or an
|
||||
# uncaught error fails the build.
|
||||
SITE_TESTS="tests/fixtures.php tests/smoke.php"
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish a release with the plugin zip attached, on whichever forge is running
|
||||
# this, and then verify the result the way a shop will actually see it.
|
||||
#
|
||||
# Needs: TAG, GITHUB_REPOSITORY, GITHUB_API_URL, GITHUB_SERVER_URL, GH_TOKEN.
|
||||
#
|
||||
# The verification at the end is the part worth keeping. The update feed is
|
||||
# read anonymously by every shop, and the two ways it breaks — a private
|
||||
# repository and a release with no zip attached — both leave a release page
|
||||
# that looks perfectly fine to whoever is logged in. Nobody would notice for
|
||||
# months; what they would notice is that updates stopped arriving.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
: "${TAG:?TAG is not set}"
|
||||
: "${GH_TOKEN:?GH_TOKEN is not set}"
|
||||
: "${GITHUB_REPOSITORY:?}"
|
||||
: "${GITHUB_API_URL:?}"
|
||||
|
||||
zip="build/$PLUGIN_SLUG.zip"
|
||||
[ -f "$zip" ] || { echo "$zip does not exist — run ci/build-zip.sh first." >&2; exit 1; }
|
||||
|
||||
owner="${GITHUB_REPOSITORY%%/*}"
|
||||
repo="${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
# The tag message is the announcement, so it has to survive the checkout.
|
||||
#
|
||||
# actions/checkout fetches every tag correctly and then overwrites this one ref
|
||||
# with the commit SHA — `fetch-depth: 0` does not save you. The annotated tag
|
||||
# becomes a lightweight one pointing at the commit, and reading its message
|
||||
# returns the COMMIT message instead: on a merge commit, "Merge pull request
|
||||
# #12 from ...". Nothing fails. The release is simply published with the wrong
|
||||
# text, and only the page shows it.
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
|
||||
[ "$(git cat-file -t "$TAG")" = tag ] || {
|
||||
echo "$TAG is not an annotated tag. Its message is what gets published, so create it with 'git tag -a'." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
notes=$(git tag -l --format='%(contents)' "$TAG")
|
||||
first_line=$(printf '%s\n' "$notes" | head -1)
|
||||
|
||||
case "$first_line" in
|
||||
'Merge pull request'*|'Merge branch'*|'')
|
||||
echo "The release notes read '$first_line'." >&2
|
||||
echo "That is a commit message, not the tag message — the annotated tag did not survive the checkout." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ci_step "release notes"
|
||||
printf '%s\n' "$notes" | sed 's/^/ /'
|
||||
|
||||
# The updater reads this line out of the release body, so wp-admin can show
|
||||
# what this version was tested against rather than what the last one claimed.
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
tested_wp=$([ -n "$readme" ] && sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||
tested_wc=$(ci_plugin_header 'WC tested up to')
|
||||
|
||||
body=$(printf '%s\n\n---\n\nTested up to: %s\nWC tested up to: %s\n' \
|
||||
"$notes" "${tested_wp:-unknown}" "${tested_wc:-unknown}")
|
||||
|
||||
api() { # method path [data]
|
||||
curl -fsS -X "$1" \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Content-Type: application/json' \
|
||||
${3:+-d "$3"} \
|
||||
"$2"
|
||||
}
|
||||
|
||||
if [ "${GITHUB_SERVER_URL:-}" = 'https://github.com' ]; then
|
||||
ci_step "publishing to GitHub"
|
||||
gh release create "$TAG" "$zip" --title "$TAG" --notes "$body" 2>/dev/null \
|
||||
|| gh release upload "$TAG" "$zip" --clobber
|
||||
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG"
|
||||
else
|
||||
ci_step "publishing to $GITHUB_SERVER_URL"
|
||||
payload=$(printf '%s' "$body" | python3 -c '
|
||||
import json, sys, os
|
||||
print(json.dumps({
|
||||
"tag_name": os.environ["TAG"],
|
||||
"name": os.environ["TAG"],
|
||||
"body": sys.stdin.read(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))')
|
||||
|
||||
# A re-run of a release must not fail on "already exists".
|
||||
existing=$(curl -fsS -H "Authorization: token $GH_TOKEN" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG" 2>/dev/null || true)
|
||||
id=$(printf '%s' "$existing" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
created=$(api POST "$GITHUB_API_URL/repos/$owner/$repo/releases" "$payload")
|
||||
id=$(printf '%s' "$created" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "Could not create or find the release for $TAG." >&2; exit 1; }
|
||||
echo " release id $id"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-F "attachment=@$zip" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id/assets?name=$PLUGIN_SLUG.zip" >/dev/null
|
||||
echo " attached $PLUGIN_SLUG.zip"
|
||||
|
||||
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/latest"
|
||||
fi
|
||||
|
||||
ci_step "the feed, read the way a shop reads it"
|
||||
# Deliberately no token on any request below. A "check" that sends credentials
|
||||
# the shops do not have is the false positive that hides exactly this bug.
|
||||
feed=$(curl -fsS --max-time 30 "$release_api" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$feed" ]; then
|
||||
echo
|
||||
ci_fail "$release_api cannot be read without credentials, so no shop will ever be offered this update."
|
||||
echo "Either the repository is still private, or the release was not created."
|
||||
echo "For Gitea: Settings → make the repository public. The update feed is a public read by design;"
|
||||
echo "that is the whole mechanism, and there is nothing secret in a GPL plugin zip anyway."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package=$(printf '%s' "$feed" | tr ',' '\n' | sed -n -E 's/.*"browser_download_url":[[:space:]]*"([^"]*\.zip)".*/\1/p' | head -1)
|
||||
if [ -z "$package" ]; then
|
||||
echo
|
||||
ci_fail "The release is readable, but it has no zip attached. WordPress would be offered an update it cannot download."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 60 "$package")
|
||||
echo " $package -> HTTP $code"
|
||||
[ "$code" = 200 ] || { ci_fail "The package URL is not anonymously downloadable."; exit 1; }
|
||||
|
||||
echo
|
||||
echo "Published $TAG. A shop running this plugin will be offered it within twelve hours,"
|
||||
echo "or immediately on Dashboard → Updates → Check again."
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stand up a throwaway WordPress + WooCommerce, install this plugin into it,
|
||||
# and run the site tests from ci/plugin.conf inside it.
|
||||
#
|
||||
# WP_VERSION=latest|nightly|6.8 WC_VERSION=latest|beta|11.1.0 PHP_VERSION=8.3
|
||||
#
|
||||
# The point of the version knobs is the question this whole directory exists to
|
||||
# answer: *will the shop still work after I press update?* Running the same
|
||||
# tests against `latest` and against `nightly`/`beta` answers it before the
|
||||
# update happens rather than after.
|
||||
#
|
||||
# Written for a Gitea Actions job, which runs INSIDE a container that talks to
|
||||
# the HOST's docker daemon. A bind mount of $PWD there mounts an empty
|
||||
# directory nobody created, and the symptom is "Could not open input file",
|
||||
# which reads like a typo in the path. So the plugin goes in over `docker cp`
|
||||
# and all state lives in named volumes. That also makes the script behave
|
||||
# identically on GitHub's runners and on a laptop.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
WP_VERSION="${WP_VERSION:-latest}"
|
||||
WC_VERSION="${WC_VERSION:-latest}"
|
||||
PHP_VERSION="${PHP_VERSION:-8.3}"
|
||||
|
||||
id="wpci-$$"
|
||||
net="$id-net"
|
||||
cleanup() {
|
||||
docker rm -f "$id-db" "$id-cli" >/dev/null 2>&1 || true
|
||||
docker volume rm -f "$id-wp" >/dev/null 2>&1 || true
|
||||
docker network rm "$net" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "WordPress=$WP_VERSION WooCommerce=$WC_VERSION PHP=$PHP_VERSION plugin=$PLUGIN_SLUG"
|
||||
|
||||
ci_step "database"
|
||||
docker network create "$net" >/dev/null
|
||||
docker volume create "$id-wp" >/dev/null
|
||||
docker run -d --name "$id-db" --network "$net" \
|
||||
-e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=wordpress \
|
||||
-e MARIADB_USER=wordpress -e MARIADB_PASSWORD=wordpress mariadb:lts >/dev/null
|
||||
|
||||
# wordpress:cli runs as www-data (33); a fresh named volume is root-owned, so
|
||||
# ownership is fixed from a root container before anything tries to write.
|
||||
docker run --rm -v "$id-wp:/var/www/html" alpine chown 33:33 /var/www/html
|
||||
docker run -d --name "$id-cli" --network "$net" -v "$id-wp:/var/www/html" \
|
||||
-e WORDPRESS_DB_HOST="$id-db" "wordpress:cli-php$PHP_VERSION" sleep infinity >/dev/null
|
||||
|
||||
# The image ships no php.ini, so PHP's 128M default applies and `wp core
|
||||
# download` dies unpacking the zip. WP_CLI_PHP_ARGS does not help here: the
|
||||
# phar is executed directly, not through the wp launcher.
|
||||
docker exec -u root "$id-cli" sh -c 'echo memory_limit=512M > /usr/local/etc/php/conf.d/zz-memory.ini'
|
||||
|
||||
wp() { docker exec "$id-cli" wp "$@"; }
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
docker exec "$id-db" mariadb -uwordpress -pwordpress -e 'select 1' wordpress >/dev/null 2>&1 && break
|
||||
if [ "$i" = 60 ]; then echo "MariaDB never came up." >&2; exit 1; fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
ci_step "WordPress $WP_VERSION"
|
||||
if [ "$WP_VERSION" = latest ]; then
|
||||
wp core download --quiet
|
||||
else
|
||||
wp core download --version="$WP_VERSION" --quiet
|
||||
fi
|
||||
wp config create --dbname=wordpress --dbuser=wordpress --dbpass=wordpress \
|
||||
--dbhost="$id-db" --quiet
|
||||
# Deprecations are the way a WordPress or PHP upgrade actually breaks a plugin:
|
||||
# nothing fails, a notice appears, and six months later it is a fatal. They are
|
||||
# logged to a file (not the page) and checked at the end of this script.
|
||||
wp config set WP_DEBUG true --raw --quiet
|
||||
wp config set WP_DEBUG_LOG true --raw --quiet
|
||||
wp config set WP_DEBUG_DISPLAY false --raw --quiet
|
||||
wp core install --url=http://plugin.test --title="$PLUGIN_SLUG CI" \
|
||||
--admin_user=admin --admin_password=admin --admin_email=ci@example.com \
|
||||
--skip-email --quiet
|
||||
echo " installed $(wp core version)"
|
||||
|
||||
if [ "$NEEDS_WOOCOMMERCE" = yes ]; then
|
||||
ci_step "WooCommerce $WC_VERSION"
|
||||
case "$WC_VERSION" in
|
||||
latest)
|
||||
wp plugin install woocommerce --activate --quiet
|
||||
;;
|
||||
beta)
|
||||
# Resolved inside the container, which has PHP and network; the host
|
||||
# runner is not guaranteed either.
|
||||
target=$(docker exec -i -e SLUG=woocommerce "$id-cli" php < ci/latest-prerelease.php)
|
||||
if [ -n "$target" ]; then
|
||||
echo " prerelease available: $target"
|
||||
wp plugin install woocommerce --version="$target" --activate --quiet
|
||||
else
|
||||
echo " no prerelease newer than stable right now — using the stable release"
|
||||
wp plugin install woocommerce --activate --quiet
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
wp plugin install woocommerce --version="$WC_VERSION" --activate --quiet
|
||||
;;
|
||||
esac
|
||||
# HPOS, because that is what a shop set up in the last few years runs, and a
|
||||
# plugin that touches orders behaves differently under it.
|
||||
wp option update woocommerce_custom_orders_table_enabled yes >/dev/null
|
||||
echo " installed $(wp plugin get woocommerce --field=version)"
|
||||
fi
|
||||
|
||||
ci_step "$PLUGIN_SLUG"
|
||||
# Exactly the files the release zip will contain, plus the site tests. Testing
|
||||
# the working tree instead would test a plugin no shop ever receives: one
|
||||
# unanchored .gitignore line is enough for a file to be present locally,
|
||||
# absent from the release, and never missed until a customer installs it.
|
||||
{ ./ci/dist-files.sh; printf '%s\n' ${SITE_TESTS:-}; } | tr '\n' '\0' | tar --null -T - -cf - \
|
||||
| docker exec -i "$id-cli" sh -c \
|
||||
"mkdir -p /var/www/html/wp-content/plugins/$PLUGIN_SLUG \
|
||||
&& tar -xf - -C /var/www/html/wp-content/plugins/$PLUGIN_SLUG"
|
||||
wp plugin activate "$PLUGIN_SLUG" --quiet
|
||||
wp plugin list --name="$PLUGIN_SLUG" --field=status | grep -qx active \
|
||||
|| { echo "The plugin did not stay active after activation." >&2; exit 1; }
|
||||
echo " activated $(wp plugin get "$PLUGIN_SLUG" --field=version)"
|
||||
|
||||
for f in ${SITE_TESTS:-}; do
|
||||
ci_step "$f"
|
||||
wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f"
|
||||
done
|
||||
|
||||
ci_step "PHP notices and deprecations"
|
||||
# Only lines naming this plugin. WordPress core and WooCommerce deprecate their
|
||||
# own things on every release, and failing on those would make the build red
|
||||
# for something nobody here can fix — which is how a red build gets ignored.
|
||||
log=$(wp eval 'echo WP_CONTENT_DIR;')/debug.log
|
||||
if docker exec "$id-cli" test -f "$log"; then
|
||||
if docker exec "$id-cli" grep -F "/plugins/$PLUGIN_SLUG/" "$log"; then
|
||||
echo
|
||||
ci_fail "The plugin logged the notices above on WordPress $(wp core version). They are the shape an update breaks in: harmless now, fatal two releases later."
|
||||
exit 1
|
||||
fi
|
||||
echo " none from this plugin"
|
||||
else
|
||||
echo " nothing logged at all"
|
||||
fi
|
||||
|
||||
ci_step "done"
|
||||
echo "WordPress $(wp core version)$( [ "$NEEDS_WOOCOMMERCE" = yes ] && echo " + WooCommerce $(wp plugin get woocommerce --field=version)" ) on PHP $PHP_VERSION: green"
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks that need no WordPress: syntax, plugin hygiene, version consistency,
|
||||
# and whether the plugin header still tells the truth about what it supports.
|
||||
#
|
||||
# Fast enough to run before every commit. `ci/site-test.sh` is the slow half.
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
fails=0
|
||||
note_fail() { ci_fail "$1"; fails=$((fails + 1)); }
|
||||
|
||||
# Only files that actually reach a server. A tool under ci/ has no business
|
||||
# carrying an ABSPATH guard, and a test file is not shipped at all. Same list
|
||||
# the release zip is built from, so the two cannot drift.
|
||||
shipped_files() {
|
||||
./ci/dist-files.sh | grep '\.php$' || true
|
||||
}
|
||||
|
||||
ci_step "PHP syntax"
|
||||
# The header promises a minimum PHP version; without it in the loop a piece of
|
||||
# newer syntax passes here and fatals on the customer's server instead.
|
||||
min_php=$(ci_plugin_header 'Requires PHP')
|
||||
tarball=$(mktemp -t wpci-lint-XXXXXX.tar)
|
||||
git ls-files -z '*.php' | tar --null -T - -cf "$tarball"
|
||||
for v in "${min_php:-7.4}" 8.4; do
|
||||
echo " php $v"
|
||||
# docker cp of a tarball, not -v "$PWD":/src — a Gitea Actions job runs
|
||||
# inside a container talking to the HOST's docker daemon, so a host path
|
||||
# here mounts an empty directory nobody created and the lint passes by
|
||||
# linting nothing. Copying a directory needs its parent to already exist in
|
||||
# the container, which /src does not; a single file into /tmp always works.
|
||||
cid=$(docker create "php:$v-cli" sh -c '
|
||||
set -e
|
||||
mkdir -p /src && tar -xf /tmp/src.tar -C /src
|
||||
n=0
|
||||
for f in $(find /src -name "*.php"); do php -l "$f" >/dev/null; n=$((n + 1)); done
|
||||
echo " $n files parse"
|
||||
# Guards against the failure this step had on its first run: an empty /src
|
||||
# makes the loop a no-op and the whole check reports success.
|
||||
[ "$n" -gt 0 ] || { echo " no PHP files arrived in the container" >&2; exit 1; }')
|
||||
docker cp "$tarball" "$cid:/tmp/src.tar" >/dev/null
|
||||
code=0
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
[ "$code" = 0 ] || note_fail "PHP $v cannot parse this plugin."
|
||||
done
|
||||
rm -f "$tarball"
|
||||
|
||||
ci_step "protection against direct access"
|
||||
before=$fails
|
||||
# Without the guard a PHP file can be requested straight from the browser,
|
||||
# outside WordPress and therefore outside every capability check the code
|
||||
# assumes has already happened.
|
||||
while read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
grep -q "defined( 'ABSPATH' )\|defined( 'WP_UNINSTALL_PLUGIN' )" "$f" \
|
||||
|| { ci_annotate error "$f" 1 "Missing the guard against direct access: if ( ! defined( 'ABSPATH' ) ) { exit; }"; fails=$((fails + 1)); }
|
||||
done <<< "$(shipped_files)"
|
||||
[ "$fails" = "$before" ] && echo " every shipped file is guarded"
|
||||
|
||||
ci_step "no debug leftovers"
|
||||
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then
|
||||
note_fail "Debug functions must not ship."
|
||||
else
|
||||
echo " none"
|
||||
fi
|
||||
|
||||
ci_step "no short open tags, no closing ?>"
|
||||
before=$fails
|
||||
# With short_open_tag off the code is printed to the visitor as text instead of
|
||||
# being run; whitespace after a closing tag is sent to the browser and breaks
|
||||
# headers and redirects.
|
||||
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then
|
||||
note_fail "Use <?php, not short open tags."
|
||||
fi
|
||||
while read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
|
||||
&& { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); }
|
||||
done <<< "$(git ls-files '*.php')"
|
||||
[ "$fails" = "$before" ] && echo " clean"
|
||||
|
||||
ci_step "text domain"
|
||||
php ci/check-textdomain.php "$TEXT_DOMAIN" . || fails=$((fails + 1))
|
||||
|
||||
ci_step "every file the plugin loads is a file the release contains"
|
||||
# The bug this exists for: a new class file is written, required from the main
|
||||
# file, works perfectly in every local test because it is sitting right there
|
||||
# in the working tree — and is not committed, so the release zip does not have
|
||||
# it. This plugin degrades instead of fataling on a missing include, which is
|
||||
# good for a half-finished upgrade and terrible here: the shop would just
|
||||
# quietly lose a feature.
|
||||
shipped=$(./ci/dist-files.sh)
|
||||
referenced=$(printf '%s\n' "$shipped" | grep '\.php$' | xargs grep -ohE "'[A-Za-z0-9_./-]+\.php'" 2>/dev/null | tr -d "'" | sort -u)
|
||||
checked=0
|
||||
for path in $referenced; do
|
||||
# A plugin-relative include always names a directory of this plugin. That
|
||||
# rules out the look-alikes: "admin-ajax.php" and "admin.php" are admin URLs
|
||||
# and menu slugs, "woocommerce/woocommerce.php" is another plugin's basename,
|
||||
# and "wp-admin/includes/upgrade.php" is resolved against ABSPATH.
|
||||
case "$path" in */*) ;; *) continue ;; esac
|
||||
[ -d "${path%%/*}" ] || continue
|
||||
checked=$((checked + 1))
|
||||
printf '%s\n' "$shipped" | grep -qx "$path" \
|
||||
|| note_fail "The code loads '$path', but the release zip does not contain it. Committed?"
|
||||
done
|
||||
echo " $checked plugin-relative include(s) checked"
|
||||
|
||||
ci_step "one version, stated once"
|
||||
# WordPress compares the update feed against the header and nothing else, so a
|
||||
# constant or a readme that disagrees with it is how a release ends up either
|
||||
# offered forever or never offered at all.
|
||||
version=$(ci_plugin_version)
|
||||
echo " plugin header: $version"
|
||||
[ -n "$version" ] || note_fail "Could not read Version: from $PLUGIN_MAIN."
|
||||
|
||||
while read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
const=${line%%=*}
|
||||
value=${line#*=}
|
||||
echo " $const: $value"
|
||||
[ "$value" = "$version" ] \
|
||||
|| note_fail "$const is $value but the plugin header says $version."
|
||||
done <<< "$(sed -n -E "s/^[[:space:]]*define\([[:space:]]*'([A-Z_]*VERSION)'[[:space:]]*,[[:space:]]*'([^']+)'.*/\1=\2/p" "$PLUGIN_MAIN")"
|
||||
|
||||
for readme in readme.txt README.txt; do
|
||||
[ -f "$readme" ] || continue
|
||||
stable=$(sed -n -E 's/^Stable tag:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||
echo " $readme stable tag: ${stable:-<none>}"
|
||||
[ "$stable" = "$version" ] \
|
||||
|| note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version."
|
||||
done
|
||||
|
||||
ci_step "the compatibility headers still tell the truth"
|
||||
# Advisory, never fatal. WordPress ships a new version whether or not anything
|
||||
# here is wrong, and a build that goes red on release day for a header nobody
|
||||
# has had a chance to update yet is a build that gets ignored. What this does
|
||||
# is make the staleness visible; compat.yml opens the PR that fixes it, but
|
||||
# only once the real tests have passed against the new version.
|
||||
|
||||
# stable-check, not version-check: version-check's payload also carries old
|
||||
# offers, and a loose match on it returned "4.7.35" as the current release.
|
||||
current_wp=$(curl -fsS --max-time 20 'https://api.wordpress.org/core/stable-check/1.0/' 2>/dev/null \
|
||||
| tr ',' '\n' | sed -n -E 's/.*"([0-9][^"]*)"[[:space:]]*:[[:space:]]*"latest".*/\1/p' | head -1)
|
||||
current_wc=$(curl -fsS --max-time 20 'https://api.wordpress.org/plugins/info/1.0/woocommerce.json' 2>/dev/null \
|
||||
| sed -n -E 's/.*"version":"([^"]+)".*/\1/p' | head -1)
|
||||
|
||||
behind() { # claimed current -> true when claimed is the older of the two
|
||||
[ -n "$1" ] && [ -n "$2" ] || return 1
|
||||
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
|
||||
}
|
||||
|
||||
# "Tested up to" is a readme.txt field — WordPress never reads it from the
|
||||
# plugin header, so checking the header for it reports "missing" on a plugin
|
||||
# that states it correctly. "WC tested up to" is the opposite: WooCommerce
|
||||
# reads that one from the plugin header and nowhere else.
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
|
||||
tested_wp=
|
||||
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||
echo " WordPress: ${readme:-readme} says ${tested_wp:-<missing>}, current release is ${current_wp:-<unknown>}"
|
||||
if [ -z "$tested_wp" ]; then
|
||||
echo "::warning::No 'Tested up to:' in ${readme:-the readme}. WordPress lists the plugin as untested."
|
||||
elif behind "$tested_wp" "$current_wp"; then
|
||||
echo "::warning::'Tested up to: $tested_wp' is behind WordPress $current_wp."
|
||||
fi
|
||||
|
||||
if [ "$NEEDS_WOOCOMMERCE" = yes ]; then
|
||||
tested_wc=$(ci_plugin_header 'WC tested up to')
|
||||
echo " WooCommerce: plugin header says ${tested_wc:-<missing>}, current release is ${current_wc:-<unknown>}"
|
||||
if [ -z "$tested_wc" ]; then
|
||||
echo "::warning::No 'WC tested up to:' in the plugin header. WooCommerce then shows this plugin as untested in wp-admin — that banner is exactly what a shop owner reads before deciding whether to press update."
|
||||
elif behind "$tested_wc" "$current_wc"; then
|
||||
echo "::warning::'WC tested up to: $tested_wc' is behind WooCommerce $current_wc."
|
||||
fi
|
||||
|
||||
requires_wc=$(ci_plugin_header 'WC requires at least')
|
||||
echo " WooCommerce minimum: ${requires_wc:-<missing>}"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fails" != 0 ]; then
|
||||
echo "$fails check(s) failed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Static checks: green."
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the plugin's version, as WordPress reads it: from the plugin header.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
ci_plugin_version
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Guard the trap in .gitea/workflows.
|
||||
#
|
||||
# Gitea uses .gitea/workflows IF IT EXISTS, and falls back to .github/workflows
|
||||
# only when it does not. It never merges them. So a workflow that lives only in
|
||||
# .github/ simply does not run on Gitea — no error, no run to click on. It
|
||||
# looks exactly like a runner that has not picked the job up, so you wait, and
|
||||
# waiting never fixes it.
|
||||
#
|
||||
# Workflows that must behave identically on both forges are therefore kept
|
||||
# byte-identical in both roots, and this script fails when they drift or when
|
||||
# one appears in only one root without a stated reason.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Workflows that are deliberately single-forge, and why.
|
||||
declare -A only_on=(
|
||||
["auto-merge.yml"]="github: it drives the GitHub merge API. Gitea merges the owner's green PRs itself."
|
||||
["release.yml"]="both, but different: each publishes to the forge it runs on, and Gitea is where the update feed lives."
|
||||
)
|
||||
|
||||
fail=0
|
||||
|
||||
for root in .github .gitea; do
|
||||
[ -d "$root/workflows" ] || { echo "$root/workflows is missing."; fail=1; }
|
||||
done
|
||||
|
||||
for f in .github/workflows/*.yml .gitea/workflows/*.yml; do
|
||||
[ -e "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
gh=".github/workflows/$name"
|
||||
gt=".gitea/workflows/$name"
|
||||
|
||||
case "${only_on[$name]:-}" in
|
||||
github:*)
|
||||
[ -f "$gt" ] && { echo "$name is marked GitHub-only but exists in .gitea/workflows too."; fail=1; }
|
||||
continue
|
||||
;;
|
||||
both,*|"both, "*)
|
||||
# Present in both, intentionally different. Both copies must exist.
|
||||
{ [ -f "$gh" ] && [ -f "$gt" ]; } \
|
||||
|| { echo "$name is expected in both roots, but one copy is missing."; fail=1; }
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$gh" ] || [ ! -f "$gt" ]; then
|
||||
echo "$name exists in only one workflow root and is not listed in ci/workflows-in-sync.sh."
|
||||
echo " A workflow missing from .gitea/workflows does not run on Gitea, silently."
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! cmp -s "$gh" "$gt"; then
|
||||
echo "$name has drifted between the two roots:"
|
||||
diff -u "$gh" "$gt" || true
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Measured on Gitea 1.27.2: a workflow-level `concurrency:` block produced no
|
||||
# run at all — not a failed one, not a cancelled one. It may have been fixed
|
||||
# since, but the cost of keeping the rule is nil and the cost of rediscovering
|
||||
# it is an afternoon of blaming the runner.
|
||||
if grep -rn '^concurrency:' .gitea/workflows/ 2>/dev/null; then
|
||||
echo "A workflow-level concurrency: block can stop a Gitea workflow from running at all."
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# A workflow that does not parse produces no run — the same symptom as every
|
||||
# other trap in this file. ci.yml cannot catch its own syntax error this way,
|
||||
# but release.yml and compat.yml can be caught here rather than on the tag.
|
||||
if python3 -c 'import yaml' 2>/dev/null; then
|
||||
for f in .github/workflows/*.yml .gitea/workflows/*.yml; do
|
||||
[ -e "$f" ] || continue
|
||||
python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$f" \
|
||||
|| { echo "$f is not valid YAML, so it will simply never run."; fail=1; }
|
||||
done
|
||||
else
|
||||
echo "(PyYAML is not installed here, so the workflow files were not parsed.)"
|
||||
fi
|
||||
|
||||
[ "$fail" = 0 ] || exit 1
|
||||
echo "Workflows: in sync."
|
||||
@@ -0,0 +1,97 @@
|
||||
# Using this setup in another plugin
|
||||
|
||||
Everything here was built to be lifted into the next WordPress plugin. Nothing
|
||||
under `ci/` knows this plugin's name except `ci/plugin.conf`.
|
||||
|
||||
There is no shared repository and no reusable workflow to `uses:`. That is
|
||||
deliberate: these repositories live on two forges, and a cross-forge
|
||||
`workflow_call` resolves on GitHub and fails on Gitea. Copying six files and
|
||||
editing one is a smaller cost than a dependency that works in half the places
|
||||
it runs.
|
||||
|
||||
## The port
|
||||
|
||||
**1. Copy these, unchanged:**
|
||||
|
||||
```
|
||||
ci/ every script; plugin.conf is the only one you edit
|
||||
.github/workflows/ ci.yml, compat.yml, release.yml, auto-merge.yml
|
||||
.gitea/workflows/ ci.yml, compat.yml, release.yml, README.md
|
||||
.distignore
|
||||
```
|
||||
|
||||
**2. Rewrite `ci/plugin.conf`** — five values, all commented in the file:
|
||||
|
||||
```sh
|
||||
PLUGIN_SLUG=your-plugin
|
||||
PLUGIN_MAIN=your-plugin.php
|
||||
TEXT_DOMAIN=your-plugin
|
||||
NEEDS_WOOCOMMERCE=yes # no, for a plugin that does not need it
|
||||
SITE_TESTS="tests/fixtures.php tests/smoke.php"
|
||||
```
|
||||
|
||||
**3. Adjust `.distignore`** for that plugin's internal documents. Everything
|
||||
left in `wp-content/plugins/` can in principle be fetched over HTTP from a live
|
||||
shop, so notes and tooling stay out of the zip.
|
||||
|
||||
**4. Write `tests/fixtures.php` and `tests/smoke.php`.** These are the only
|
||||
genuinely new work. Use this plugin's as the shape: `wc_edm_ok()` /
|
||||
`wc_edm_is()` accumulate failures so one run reports all of them, and the file
|
||||
exits non-zero at the end. They run under `wp eval-file`, so WordPress and
|
||||
WooCommerce are fully loaded and every API is the real one.
|
||||
|
||||
**5. Copy `includes/class-updater.php`** and rename the class, the `TRANSIENT`
|
||||
constant, the plugin-file constant and the user-agent string. Then add to the
|
||||
plugin header:
|
||||
|
||||
```
|
||||
Update URI: https://gitea.nolimit.dk/kw/your-plugin
|
||||
```
|
||||
|
||||
and instantiate the updater **before** any "is WooCommerce active" gate — a
|
||||
shop with WooCommerce switched off for an afternoon must not quietly stop
|
||||
receiving fixes.
|
||||
|
||||
**6. Make the Gitea repository public**, or point `Update URI` at a separate
|
||||
public releases-only repository. Every shop reads that feed without
|
||||
credentials; `ci/publish-release.sh` fails the release with this instruction if
|
||||
it cannot.
|
||||
|
||||
## What you get
|
||||
|
||||
- `ci/static.sh` — syntax on the oldest and newest PHP, the direct-access
|
||||
guard on every shipped file, no debug leftovers, no short tags, text domain
|
||||
on every translation call, one version stated once across header, constant
|
||||
and readme, and a warning when the compatibility headers have gone stale.
|
||||
- `ci/site-test.sh` — a real WordPress + WooCommerce at any versions you name,
|
||||
including `nightly` and `beta`, failing on any deprecation from your own code.
|
||||
- `ci/build-zip.sh` — an installable zip, checked for the main file, for a
|
||||
single top-level directory named for the slug, and for leaked tooling.
|
||||
- `compat.yml` — the weekly run against the next WordPress and the next
|
||||
WooCommerce, and a pull request that bumps the compatibility headers once
|
||||
that run is green.
|
||||
- `release.yml` — tag, test, build, publish, then read the feed back with no
|
||||
credentials to prove a shop can download it.
|
||||
- `ci/workflows-in-sync.sh` — fails when the two workflow roots drift, which is
|
||||
the only way to notice that a workflow stopped running on Gitea.
|
||||
|
||||
## Two guards worth understanding before you move them
|
||||
|
||||
**`.gitea/workflows/` hides `.github/workflows/` completely.** Gitea uses the
|
||||
former if it exists and never merges the two. Adding one Gitea-only workflow
|
||||
switches off CI for the whole repository there, with no error and no run to
|
||||
click on — it looks exactly like a runner that has not picked the job up. Every
|
||||
workflow that must run on Gitea has to be in that directory.
|
||||
|
||||
**The release reads its own feed anonymously afterwards.** A private repository
|
||||
and a release with no zip attached both produce a page that looks perfect to
|
||||
anyone logged in, and an update that no shop is ever offered. A check that
|
||||
sends a token is the false positive that hides exactly that.
|
||||
|
||||
## Candidates already in the fleet
|
||||
|
||||
`spool` and `subscriptions` are both WooCommerce plugins with some of this
|
||||
already: `spool` has an earlier version of the throwaway-site script, and
|
||||
`subscriptions` has the hygiene checks and a `.distignore`. Neither has the
|
||||
version matrix, the compatibility schedule or the updater. Porting them is
|
||||
steps 1–5 above.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Testing, releases and automatic updates
|
||||
|
||||
Three questions, answered by three things in this repository.
|
||||
|
||||
| Question | Answer |
|
||||
| --- | --- |
|
||||
| Does it still work on the newest WordPress and WooCommerce? | `ci/site-test.sh`, run weekly by `compat.yml` against `latest`, `nightly` and `beta` |
|
||||
| Can updates be pushed out automatically? | Yes — `includes/class-updater.php` plus `release.yml` |
|
||||
| What happens when *I* press update in wp-admin? | Nothing surprising, because the release was tested against those versions before it was published |
|
||||
|
||||
---
|
||||
|
||||
## Running the tests
|
||||
|
||||
```sh
|
||||
ci/static.sh # syntax, hygiene, versions, headers — seconds
|
||||
ci/site-test.sh # a real WordPress + WooCommerce — a few minutes
|
||||
ci/build-zip.sh # what a shop actually installs
|
||||
```
|
||||
|
||||
`ci/site-test.sh` takes three knobs:
|
||||
|
||||
```sh
|
||||
WP_VERSION=latest # or nightly, or an exact version like 7.0
|
||||
WC_VERSION=latest # or beta, or an exact version like 11.1.0
|
||||
PHP_VERSION=8.3 # any tag of the wordpress:cli image
|
||||
```
|
||||
|
||||
It builds a throwaway site in Docker containers, installs WordPress and
|
||||
WooCommerce at those versions, copies in **exactly the files the release zip
|
||||
will contain**, activates the plugin, and runs `tests/fixtures.php` and
|
||||
`tests/smoke.php` inside it with `wp eval-file`.
|
||||
|
||||
The tests use a real cart, real coupons and real `WC_Shipping_Rate` objects.
|
||||
That is the point. A mocked cart keeps passing after WooCommerce has changed
|
||||
underneath it, which is the one failure this whole directory exists to catch.
|
||||
|
||||
Everything is torn down afterwards — containers, volumes and the network — so
|
||||
running it twice leaves nothing behind.
|
||||
|
||||
### Deprecations count as failures
|
||||
|
||||
The site test switches on `WP_DEBUG` and fails the build on any notice logged
|
||||
from this plugin's directory. That is how a WordPress or PHP upgrade actually
|
||||
breaks a plugin: nothing fails, a notice appears, and two releases later it is
|
||||
a fatal. Notices from WordPress core and WooCommerce are ignored — failing on
|
||||
those would make the build red for something nobody here can fix, and a build
|
||||
that is always red is a build that gets ignored.
|
||||
|
||||
---
|
||||
|
||||
## The weekly compatibility run
|
||||
|
||||
`compat.yml` runs the matrix every Monday at 06:00 and on demand:
|
||||
|
||||
| WordPress | WooCommerce | PHP | What it tells you |
|
||||
| --- | --- | --- | --- |
|
||||
| latest | latest | 8.3 | today's shop |
|
||||
| **nightly** | latest | 8.3 | the next WordPress, weeks early |
|
||||
| latest | **beta** | 8.3 | the next WooCommerce, weeks early |
|
||||
| latest | latest | 7.4 | the floor the plugin header promises |
|
||||
| latest | latest | 8.4 | where deprecations appear first |
|
||||
|
||||
The `beta` cell falls back to the stable release when there is no open beta, so
|
||||
it is never silently skipped.
|
||||
|
||||
When the whole matrix passes, a second job compares `Tested up to:` and
|
||||
`WC tested up to:` against the current releases and opens a pull request if
|
||||
they are behind. It runs only after the matrix is green, because those headers
|
||||
are a claim — WooCommerce shows `WC tested up to` to the shop owner as an
|
||||
untested-plugin banner, and that banner is what they read before deciding
|
||||
whether to press update.
|
||||
|
||||
The schedule fires on both forges; the matrix job skips the scheduled run on
|
||||
Gitea so the single self-hosted runner is not asked to repeat GitHub's work.
|
||||
`workflow_dispatch` still works there.
|
||||
|
||||
---
|
||||
|
||||
## Cutting a release
|
||||
|
||||
1. Bump `Version:` in the plugin header. `ci/static.sh` checks that
|
||||
`WC_EDM_VERSION` and the readme's `Stable tag:` agree with it — WordPress
|
||||
compares the update feed against the header and nothing else, so a
|
||||
disagreement means the update is offered again immediately after it is
|
||||
installed, forever.
|
||||
2. Add a changelog section to `README.txt`.
|
||||
3. Merge to `main`.
|
||||
4. Tag it **annotated**, because the message is the release notes:
|
||||
```sh
|
||||
git tag -a v1.4.0 -m "..."
|
||||
git push origin v1.4.0 # origin pushes to GitHub and Gitea both
|
||||
```
|
||||
|
||||
`release.yml` then runs the static checks, installs the plugin into a real
|
||||
WordPress + WooCommerce and runs the full test suite, builds the zip, publishes
|
||||
the release, and finally **reads the feed back anonymously** to prove a shop
|
||||
can actually download it.
|
||||
|
||||
Two things in there are guards against bugs that are invisible without them:
|
||||
|
||||
- **The tag message is re-fetched before it is read.** `actions/checkout`
|
||||
fetches every tag correctly and then overwrites the triggering one with the
|
||||
commit SHA, turning an annotated tag into a lightweight one. Reading it then
|
||||
returns the *commit* message — on a merge commit, "Merge pull request #12
|
||||
from …". Nothing fails; the release is just published with the wrong text.
|
||||
- **The anonymous read-back sends no credentials.** The two ways the feed
|
||||
breaks — a private repository and a release with no zip attached — both
|
||||
leave a release page that looks perfectly fine to anyone logged in.
|
||||
|
||||
---
|
||||
|
||||
## How the automatic updates work
|
||||
|
||||
The plugin header carries:
|
||||
|
||||
```
|
||||
Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
```
|
||||
|
||||
`includes/class-updater.php` hooks `update_plugins_gitea.nolimit.dk`, the
|
||||
filter WordPress fires for plugins whose `Update URI` points at that host. It
|
||||
reads `/api/v1/repos/kw/<repo>/releases/latest`, takes the tag as the version
|
||||
and the attached `.zip` as the package, and hands both to WordPress. WordPress
|
||||
does the version comparison, the download, the unpacking and the rollback.
|
||||
|
||||
That means all of this works with no extra code:
|
||||
|
||||
- the update notice on **Plugins** and **Dashboard → Updates**
|
||||
- the **update now** link
|
||||
- the per-plugin **Enable auto-updates** link — WordPress builds that from the
|
||||
plugins it has an answer for, which is why the updater answers even when the
|
||||
installed version is already current
|
||||
- `wp plugin update woocommerce-email-discount-manager` over WP-CLI
|
||||
|
||||
`Update URI` earns its place a second way: since WordPress 5.8, a plugin that
|
||||
declares one is **never asked about on wordpress.org**. Without it, a .org
|
||||
plugin that happened to share this slug could be pushed onto a shop running
|
||||
this one.
|
||||
|
||||
### One-time setup
|
||||
|
||||
The repository on Gitea must be **public**, because every shop reads that feed
|
||||
without credentials. Settings → make it public. Nothing in a GPL plugin zip is
|
||||
secret, and the release workflow fails loudly with this exact instruction if it
|
||||
is not done.
|
||||
|
||||
If you would rather keep the source private, create a separate public repo that
|
||||
holds only releases and point `Update URI` at that instead — the updater
|
||||
derives everything else from the header, so that is a one-line change.
|
||||
|
||||
### It reports nothing
|
||||
|
||||
The update check sends a plain unauthenticated GET for a file every visitor to
|
||||
that repository can read. The user agent is overridden on purpose: WordPress's
|
||||
default one is `WordPress/7.1; https://the-shop.example`, which would tell the
|
||||
update host which shops are running this plugin. Nothing else about the site
|
||||
leaves it, ever.
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
/**
|
||||
* Updates from the plugin's own release feed.
|
||||
*
|
||||
* WordPress only offers updates for plugins it knows a source for. This is
|
||||
* that source: the Gitea repository named in the plugin header's "Update URI".
|
||||
*
|
||||
* It hooks `update_plugins_{$hostname}` rather than rewriting the update
|
||||
* transient by hand. That filter is the supported path since WordPress 5.8 and
|
||||
* it brings one guarantee worth more than the tidiness: a plugin with an
|
||||
* "Update URI" pointing away from wordpress.org is never asked about there, so
|
||||
* a .org plugin that happens to share this slug can never be pushed onto a
|
||||
* shop running this one.
|
||||
*
|
||||
* Nothing about the site is sent. The request is a plain unauthenticated GET
|
||||
* for a file every visitor to that repository can read, and the user agent is
|
||||
* overridden because WordPress's default one carries the site's own URL in it.
|
||||
*
|
||||
* @package WC_Email_Discount_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers releases from the repository named in the plugin header.
|
||||
*/
|
||||
class WC_EDM_Updater {
|
||||
|
||||
/**
|
||||
* Where the answer is cached between checks.
|
||||
*/
|
||||
const TRANSIENT = 'wc_edm_release_feed';
|
||||
|
||||
/**
|
||||
* How long that cache lives. WordPress checks for updates twice a day; a
|
||||
* shorter window would only add requests, a longer one would delay a fix.
|
||||
*/
|
||||
const TTL = 6 * HOUR_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Hook into the update check for our own host only.
|
||||
*/
|
||||
public function __construct() {
|
||||
$host = $this->host();
|
||||
|
||||
if ( ! $host ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 2 );
|
||||
|
||||
// Without this the old answer is served for up to six hours after an
|
||||
// update, and the plugins screen keeps offering a version already
|
||||
// installed.
|
||||
add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository this plugin updates from, as stated in its own header.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function update_uri() {
|
||||
$data = get_file_data( WC_EDM_PLUGIN_FILE, array( 'uri' => 'Update URI' ) );
|
||||
|
||||
return isset( $data['uri'] ) ? trim( $data['uri'] ) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostname of the update URI, or an empty string when there is none.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function host() {
|
||||
$host = wp_parse_url( self::update_uri(), PHP_URL_HOST );
|
||||
|
||||
return is_string( $host ) ? $host : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer WordPress's update check for this plugin.
|
||||
*
|
||||
* Always returns what the feed says, even when it is the version already
|
||||
* installed: WordPress compares the versions itself, and a plugin that
|
||||
* answers "no update" by returning false never appears in the no_update
|
||||
* list — which is what the "Enable auto-updates" link on the plugins
|
||||
* screen is built from. Staying silent here is how a plugin ends up
|
||||
* unable to auto-update while looking perfectly configured.
|
||||
*
|
||||
* @param array|false $update Update offered so far.
|
||||
* @param array $plugin_data Headers of the installed plugin.
|
||||
* @return array|false
|
||||
*/
|
||||
public function offer_update( $update, $plugin_data ) {
|
||||
$release = $this->latest_release();
|
||||
|
||||
if ( ! $release ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
return array(
|
||||
'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ),
|
||||
'version' => $release['version'],
|
||||
'package' => $release['package'],
|
||||
'url' => $release['url'],
|
||||
'tested' => $release['tested'],
|
||||
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
|
||||
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached feed.
|
||||
*/
|
||||
public function forget() {
|
||||
delete_transient( self::TRANSIENT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the newest release from the repository.
|
||||
*
|
||||
* @return array|null { version, package, url, tested }, or null.
|
||||
*/
|
||||
private function latest_release() {
|
||||
$cached = get_transient( self::TRANSIENT );
|
||||
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
if ( 'none' === $cached ) {
|
||||
// A failure is cached too, briefly. Otherwise an unreachable host
|
||||
// turns every admin page load into a blocking HTTP request.
|
||||
return null;
|
||||
}
|
||||
|
||||
$uri = self::update_uri();
|
||||
$api = $this->api_url( $uri );
|
||||
|
||||
if ( ! $api ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
$api,
|
||||
array(
|
||||
'timeout' => 10,
|
||||
'headers' => array( 'Accept' => 'application/json' ),
|
||||
// WordPress's default user agent is
|
||||
// "WordPress/6.8; https://the-shop.example" — it would tell
|
||||
// the update host which shops are running this plugin. This
|
||||
// plugin does not report that anywhere, so it must not leak
|
||||
// it here either.
|
||||
'user-agent' => 'wc-email-discount-manager updater',
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( ! is_array( $body ) || empty( $body['tag_name'] ) ) {
|
||||
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
|
||||
return null;
|
||||
}
|
||||
|
||||
$package = '';
|
||||
$tested = '';
|
||||
|
||||
foreach ( isset( $body['assets'] ) ? $body['assets'] : array() as $asset ) {
|
||||
if ( empty( $asset['name'] ) || empty( $asset['browser_download_url'] ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( substr( $asset['name'], -4 ) === '.zip' ) {
|
||||
$package = $asset['browser_download_url'];
|
||||
}
|
||||
}
|
||||
|
||||
// The release notes carry the compatibility line the release workflow
|
||||
// writes, so wp-admin can show what this version was actually tested
|
||||
// against instead of what the previous one claimed.
|
||||
if ( ! empty( $body['body'] ) && preg_match( '/^Tested up to:\s*(\S+)/mi', $body['body'], $m ) ) {
|
||||
$tested = $m[1];
|
||||
}
|
||||
|
||||
if ( '' === $package ) {
|
||||
// A release with no zip attached is a half-finished release.
|
||||
// Offering it would hand WordPress a download it cannot install.
|
||||
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
|
||||
return null;
|
||||
}
|
||||
|
||||
$release = array(
|
||||
'version' => ltrim( $body['tag_name'], 'vV' ),
|
||||
'package' => $package,
|
||||
'url' => ! empty( $body['html_url'] ) ? $body['html_url'] : $uri,
|
||||
'tested' => $tested,
|
||||
);
|
||||
|
||||
set_transient( self::TRANSIENT, $release, self::TTL );
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the repository's web address into its "latest release" API address.
|
||||
*
|
||||
* https://gitea.example/owner/repo
|
||||
* -> https://gitea.example/api/v1/repos/owner/repo/releases/latest
|
||||
*
|
||||
* Derived rather than configured, so the plugin header stays the single
|
||||
* place that says where updates come from.
|
||||
*
|
||||
* @param string $uri Repository web address.
|
||||
* @return string Empty when the address is not a repository address.
|
||||
*/
|
||||
private function api_url( $uri ) {
|
||||
$parts = wp_parse_url( $uri );
|
||||
|
||||
if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || empty( $parts['path'] ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$segments = array_values( array_filter( explode( '/', $parts['path'] ) ) );
|
||||
|
||||
if ( count( $segments ) < 2 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s://%s/api/v1/repos/%s/%s/releases/latest',
|
||||
$parts['scheme'],
|
||||
$parts['host'],
|
||||
rawurlencode( $segments[0] ),
|
||||
rawurlencode( $segments[1] )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Build the shop state the smoke test asserts against.
|
||||
*
|
||||
* Run inside the throwaway site with `wp eval-file`, so WordPress and
|
||||
* WooCommerce are fully loaded and this is real data in real tables — not a
|
||||
* mocked cart that would keep passing after WooCommerce changed underneath it.
|
||||
*
|
||||
* @package WC_Email_Discount_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a product category and return its term ID.
|
||||
*
|
||||
* @param string $name Category name.
|
||||
* @return int
|
||||
*/
|
||||
function wc_edm_ci_category( $name ) {
|
||||
$term = term_exists( $name, 'product_cat' );
|
||||
if ( ! $term ) {
|
||||
$term = wp_insert_term( $name, 'product_cat' );
|
||||
}
|
||||
return (int) $term['term_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple product and return its ID.
|
||||
*
|
||||
* @param string $name Product name.
|
||||
* @param float $price Price.
|
||||
* @param int $category Product category term ID.
|
||||
* @return int
|
||||
*/
|
||||
function wc_edm_ci_product( $name, $price, $category ) {
|
||||
$product = new WC_Product_Simple();
|
||||
$product->set_name( $name );
|
||||
$product->set_regular_price( $price );
|
||||
$product->set_category_ids( array( $category ) );
|
||||
$product->set_catalog_visibility( 'visible' );
|
||||
$product->set_stock_status( 'instock' );
|
||||
return $product->save();
|
||||
}
|
||||
|
||||
$discounted = wc_edm_ci_category( 'CI Discounted' );
|
||||
$other = wc_edm_ci_category( 'CI Other' );
|
||||
|
||||
$product_in = wc_edm_ci_product( 'CI product in group', 100, $discounted );
|
||||
$product_out = wc_edm_ci_product( 'CI product outside group', 50, $other );
|
||||
|
||||
$group_id = WC_EDM_DB::insert_group(
|
||||
array(
|
||||
'name' => 'CI Group',
|
||||
'discount_percent' => 20,
|
||||
)
|
||||
);
|
||||
WC_EDM_DB::set_group_categories( $group_id, array( $discounted ) );
|
||||
|
||||
$coupon = new WC_Coupon();
|
||||
$coupon->set_code( 'ciflat10' );
|
||||
$coupon->set_discount_type( 'fixed_cart' );
|
||||
$coupon->set_amount( 10 );
|
||||
$coupon->save();
|
||||
|
||||
/**
|
||||
* Insert an email assignment, filling in the columns the caller did not name.
|
||||
*
|
||||
* @param array $data Partial assignment row.
|
||||
* @return int
|
||||
*/
|
||||
function wc_edm_ci_assign( $data ) {
|
||||
return WC_EDM_DB::insert_email(
|
||||
array_merge(
|
||||
array(
|
||||
'assignment_type' => 'group',
|
||||
'group_id' => null,
|
||||
'coupon_code' => null,
|
||||
'custom_discount' => null,
|
||||
'is_active' => 1,
|
||||
'free_shipping' => 0,
|
||||
),
|
||||
$data
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// The three pattern kinds, arranged so that priority is observable:
|
||||
// exact@company.com is matched by BOTH the exact rule and the domain rule, and
|
||||
// "partial" is contained in partialperson@elsewhere.test.
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => '@company.com',
|
||||
'assignment_type' => 'group',
|
||||
'group_id' => $group_id,
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'exact@company.com',
|
||||
'assignment_type' => 'custom',
|
||||
'custom_discount' => 50,
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'partial',
|
||||
'assignment_type' => 'custom',
|
||||
'custom_discount' => 5,
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'buyer@coupons.test',
|
||||
'assignment_type' => 'coupon',
|
||||
'coupon_code' => 'ciflat10',
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'switched-off@company.dk',
|
||||
'assignment_type' => 'custom',
|
||||
'custom_discount' => 90,
|
||||
'is_active' => 0,
|
||||
)
|
||||
);
|
||||
|
||||
update_option(
|
||||
'wc_edm_ci_fixtures',
|
||||
array(
|
||||
'group_id' => $group_id,
|
||||
'category' => $discounted,
|
||||
'product_in' => $product_in,
|
||||
'product_out' => $product_out,
|
||||
)
|
||||
);
|
||||
|
||||
printf(
|
||||
" fixtures: group #%d (20%%) on category #%d, products #%d (100) and #%d (50), 5 assignments, coupon ciflat10\n",
|
||||
$group_id,
|
||||
$discounted,
|
||||
$product_in,
|
||||
$product_out
|
||||
);
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
/**
|
||||
* What must still be true after a WordPress or WooCommerce upgrade.
|
||||
*
|
||||
* Run inside the throwaway site with `wp eval-file`, against real tables, a
|
||||
* real cart and real WooCommerce objects. That is the point: a mocked cart
|
||||
* keeps passing after WooCommerce has changed underneath it, which is the one
|
||||
* failure this whole directory exists to catch.
|
||||
*
|
||||
* @package WC_Email_Discount_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$wc_edm_failures = 0;
|
||||
|
||||
/**
|
||||
* Assert a condition and keep going, so one run reports every failure.
|
||||
*
|
||||
* @param string $what What is being checked.
|
||||
* @param bool $condition Result of the check.
|
||||
* @param string $detail Shown when it fails.
|
||||
*/
|
||||
function wc_edm_ok( $what, $condition, $detail = '' ) {
|
||||
global $wc_edm_failures;
|
||||
|
||||
if ( $condition ) {
|
||||
printf( " ok %s\n", $what );
|
||||
return;
|
||||
}
|
||||
|
||||
++$wc_edm_failures;
|
||||
printf( " FAIL %s%s\n", $what, '' !== $detail ? " — {$detail}" : '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert two values are equal.
|
||||
*
|
||||
* @param string $what What is being checked.
|
||||
* @param mixed $expected Expected value.
|
||||
* @param mixed $actual Actual value.
|
||||
*/
|
||||
function wc_edm_is( $what, $expected, $actual ) {
|
||||
wc_edm_ok(
|
||||
$what,
|
||||
$expected === $actual,
|
||||
sprintf( 'expected %s, got %s', var_export( $expected, true ), var_export( $actual, true ) )
|
||||
);
|
||||
}
|
||||
|
||||
$fixtures = get_option( 'wc_edm_ci_fixtures' );
|
||||
wc_edm_ok( 'fixtures were created', is_array( $fixtures ) );
|
||||
|
||||
/* ---------------------------------------------------------------- schema -- */
|
||||
|
||||
global $wpdb;
|
||||
foreach ( array( 'groups', 'group_categories', 'emails', 'shipping_rules', 'audit_log' ) as $short ) {
|
||||
$table = WC_EDM_DB::table( $short );
|
||||
wc_edm_ok(
|
||||
"table {$short} exists",
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
|
||||
$wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table,
|
||||
'activation should have created it via dbDelta()'
|
||||
);
|
||||
}
|
||||
|
||||
wc_edm_is(
|
||||
'the recorded DB version matches the plugin',
|
||||
WC_EDM_VERSION,
|
||||
get_option( 'wc_edm_db_version' )
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------- matching -- */
|
||||
|
||||
$match = WC_EDM_Helpers::find_email_match( 'exact@company.com' );
|
||||
wc_edm_is( 'an exact pattern beats the domain pattern that also matches', 'custom', $match ? $match->assignment_type : null );
|
||||
|
||||
$match = WC_EDM_Helpers::find_email_match( 'someone@company.com' );
|
||||
wc_edm_is( 'a domain pattern matches everyone on the domain', 'group', $match ? $match->assignment_type : null );
|
||||
|
||||
$match = WC_EDM_Helpers::find_email_match( 'partialperson@elsewhere.test' );
|
||||
wc_edm_is( 'a partial pattern matches anywhere in the address', '5.00', $match ? $match->custom_discount : null );
|
||||
|
||||
wc_edm_ok( 'an unknown address matches nothing', null === WC_EDM_Helpers::find_email_match( 'nobody@nowhere.test' ) );
|
||||
wc_edm_ok( 'a deactivated assignment is ignored', null === WC_EDM_Helpers::find_email_match( 'switched-off@company.dk' ) );
|
||||
wc_edm_ok( 'a malformed address matches nothing', null === WC_EDM_Helpers::find_email_match( 'not-an-email' ) );
|
||||
|
||||
/* ------------------------------------------------------------------ cart -- */
|
||||
|
||||
// wc_load_cart() is WooCommerce's own way of getting a session, a customer and
|
||||
// a cart outside a web request. Without it WC()->cart is null in WP-CLI and
|
||||
// every cart assertion below would quietly skip.
|
||||
wc_load_cart();
|
||||
wc_edm_ok( 'WooCommerce gives us a cart outside a web request', WC()->cart instanceof WC_Cart );
|
||||
|
||||
/**
|
||||
* Empty the cart, set who the customer is, and recalculate.
|
||||
*
|
||||
* @param string $email Billing email.
|
||||
* @param array $products product_id => quantity.
|
||||
*/
|
||||
function wc_edm_cart_for( $email, $products ) {
|
||||
WC()->cart->empty_cart();
|
||||
WC()->cart->remove_coupons();
|
||||
WC()->session->set( WC_EDM_Frontend::SESSION_EMAIL_KEY, null );
|
||||
WC()->customer->set_billing_email( $email );
|
||||
|
||||
foreach ( $products as $product_id => $quantity ) {
|
||||
WC()->cart->add_to_cart( $product_id, $quantity );
|
||||
}
|
||||
|
||||
WC()->cart->calculate_totals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Total of the negative fees on the cart, as a positive number.
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
function wc_edm_discount_total() {
|
||||
$total = 0;
|
||||
foreach ( WC()->cart->get_fees() as $fee ) {
|
||||
if ( $fee->amount < 0 ) {
|
||||
$total += -$fee->amount;
|
||||
}
|
||||
}
|
||||
return round( $total, 2 );
|
||||
}
|
||||
|
||||
wc_edm_cart_for( 'someone@company.com', array( $fixtures['product_in'] => 1, $fixtures['product_out'] => 1 ) );
|
||||
wc_edm_is(
|
||||
'a group discount takes 20% off the eligible item only, not the whole cart',
|
||||
20.0,
|
||||
wc_edm_discount_total()
|
||||
);
|
||||
|
||||
wc_edm_cart_for( 'someone@company.com', array( $fixtures['product_out'] => 1 ) );
|
||||
wc_edm_is( 'a cart with nothing in the group gets no discount', 0.0, wc_edm_discount_total() );
|
||||
|
||||
wc_edm_cart_for( 'exact@company.com', array( $fixtures['product_in'] => 1, $fixtures['product_out'] => 1 ) );
|
||||
wc_edm_is( 'a custom percent applies to the whole cart', 75.0, wc_edm_discount_total() );
|
||||
|
||||
wc_edm_cart_for( 'nobody@nowhere.test', array( $fixtures['product_in'] => 1 ) );
|
||||
wc_edm_is( 'an unknown customer pays full price', 0.0, wc_edm_discount_total() );
|
||||
|
||||
wc_edm_cart_for( 'buyer@coupons.test', array( $fixtures['product_in'] => 1 ) );
|
||||
wc_edm_ok(
|
||||
'a coupon assignment applies the coupon',
|
||||
in_array( 'ciflat10', WC()->cart->get_applied_coupons(), true ),
|
||||
'applied: ' . implode( ', ', WC()->cart->get_applied_coupons() )
|
||||
);
|
||||
wc_edm_is( 'and the coupon actually discounts the cart', 10.0, round( WC()->cart->get_discount_total(), 2 ) );
|
||||
|
||||
/* -------------------------------------------------------------- shipping -- */
|
||||
|
||||
WC_EDM_DB::replace_shipping_rules(
|
||||
(int) $fixtures['group_id'],
|
||||
// The admin stores the zone *instance* key ("flat_rate:1"), not the method
|
||||
// id, and the filter compares against the rate id — which is the same
|
||||
// shape. A rule holding a bare method id silently matches nothing, and the
|
||||
// "never leave the customer without shipping" fallback then hands back
|
||||
// every rate, so the misconfiguration looks exactly like no rule at all.
|
||||
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ) )
|
||||
);
|
||||
|
||||
$rates = array(
|
||||
'flat_rate:1' => new WC_Shipping_Rate( 'flat_rate:1', 'Flat rate', 10, array(), 'flat_rate' ),
|
||||
'local_pickup:2' => new WC_Shipping_Rate( 'local_pickup:2', 'Local pickup', 0, array(), 'local_pickup' ),
|
||||
);
|
||||
|
||||
WC()->customer->set_billing_email( 'someone@company.com' );
|
||||
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
||||
wc_edm_is( 'a "show" rule hides every method it does not name', array( 'flat_rate:1' ), array_keys( $filtered ) );
|
||||
|
||||
WC()->customer->set_billing_email( 'nobody@nowhere.test' );
|
||||
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
||||
wc_edm_is( 'an unmatched customer sees every method', 2, count( $filtered ) );
|
||||
|
||||
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
|
||||
|
||||
/* ------------------------------------------------------------------ rest -- */
|
||||
|
||||
$routes = rest_get_server()->get_routes();
|
||||
$ours = array_filter(
|
||||
array_keys( $routes ),
|
||||
static function ( $route ) {
|
||||
return 0 === strpos( $route, '/' . WC_EDM_REST_API::REST_NAMESPACE );
|
||||
}
|
||||
);
|
||||
wc_edm_ok( 'the REST routes are registered', count( $ours ) > 0, 'namespace ' . WC_EDM_REST_API::REST_NAMESPACE );
|
||||
|
||||
$request = new WP_REST_Request( 'GET', '/' . WC_EDM_REST_API::REST_NAMESPACE . '/groups' );
|
||||
$response = rest_get_server()->dispatch( $request );
|
||||
wc_edm_is( 'and they refuse an unauthenticated caller', 401, $response->get_status() );
|
||||
|
||||
/* ------------------------------------------------------------ woocommerce -- */
|
||||
|
||||
$compatible = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'custom_order_tables' );
|
||||
wc_edm_ok(
|
||||
'HPOS compatibility is declared',
|
||||
in_array( plugin_basename( WC_EDM_PLUGIN_FILE ), $compatible['compatible'], true ),
|
||||
'without the declaration WooCommerce shows this plugin as incompatible with High-Performance Order Storage in wp-admin'
|
||||
);
|
||||
|
||||
/* --------------------------------------------------------------- updates -- */
|
||||
|
||||
// get_plugin_data() lives in the admin includes, which are not loaded in a
|
||||
// WP-CLI context by default.
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
|
||||
$update_uri = WC_EDM_Updater::update_uri();
|
||||
wc_edm_ok(
|
||||
'the plugin header names an update source',
|
||||
'' !== $update_uri && 0 === strpos( $update_uri, 'https://' ),
|
||||
'without "Update URI:" WordPress offers no updates at all — and worse, it falls back to asking wordpress.org about this slug'
|
||||
);
|
||||
|
||||
wc_edm_ok(
|
||||
'the update filter is hooked for that host',
|
||||
has_filter( 'update_plugins_' . wp_parse_url( $update_uri, PHP_URL_HOST ) ) !== false,
|
||||
'the filter name is built from the host in the header, so a typo in either silently disables updates'
|
||||
);
|
||||
|
||||
// The API address is derived from the header rather than configured, so a
|
||||
// change to either has to keep producing a reachable endpoint.
|
||||
$derive = new ReflectionMethod( 'WC_EDM_Updater', 'api_url' );
|
||||
$derive->setAccessible( true );
|
||||
wc_edm_is(
|
||||
'the release API address is derived from the repository address',
|
||||
'https://gitea.nolimit.dk/api/v1/repos/kw/demo/releases/latest',
|
||||
$derive->invoke( new WC_EDM_Updater(), 'https://gitea.nolimit.dk/kw/demo' )
|
||||
);
|
||||
|
||||
// Feed the updater a known release through its own cache instead of the
|
||||
// network: what matters here is the shape handed to WordPress, and a test
|
||||
// that needs the internet fails for reasons that have nothing to do with it.
|
||||
set_transient(
|
||||
WC_EDM_Updater::TRANSIENT,
|
||||
array(
|
||||
'version' => '99.0.0',
|
||||
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v99.0.0/demo.zip',
|
||||
'url' => 'https://gitea.nolimit.dk/kw/demo/releases/tag/v99.0.0',
|
||||
'tested' => '7.1',
|
||||
),
|
||||
60
|
||||
);
|
||||
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
wc_edm_is( 'a newer release is offered as an update', '99.0.0', is_array( $offered ) ? $offered['version'] : null );
|
||||
wc_edm_is(
|
||||
'and it carries the folder name WordPress will unpack into',
|
||||
'woocommerce-email-discount-manager',
|
||||
is_array( $offered ) ? $offered['slug'] : null
|
||||
);
|
||||
wc_edm_ok(
|
||||
'the offer names a downloadable package',
|
||||
is_array( $offered ) && ! empty( $offered['package'] ),
|
||||
'an offer without a package is an update WordPress cannot install'
|
||||
);
|
||||
|
||||
// The same version already installed must still be answered, not skipped:
|
||||
// WordPress files that away under no_update, and that list is what the
|
||||
// per-plugin "Enable auto-updates" link on the plugins screen is built from.
|
||||
set_transient(
|
||||
WC_EDM_Updater::TRANSIENT,
|
||||
array(
|
||||
'version' => WC_EDM_VERSION,
|
||||
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v1/demo.zip',
|
||||
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
||||
'tested' => '7.1',
|
||||
),
|
||||
60
|
||||
);
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
|
||||
|
||||
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
|
||||
/* --------------------------------------------------------------- verdict -- */
|
||||
|
||||
echo "\n";
|
||||
if ( $wc_edm_failures > 0 ) {
|
||||
printf( "%d assertion(s) failed on WordPress %s.\n", $wc_edm_failures, get_bloginfo( 'version' ) );
|
||||
exit( 1 );
|
||||
}
|
||||
printf(
|
||||
"All assertions passed on WordPress %s with WooCommerce %s.\n",
|
||||
get_bloginfo( 'version' ),
|
||||
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
|
||||
);
|
||||
@@ -1,23 +1,33 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: WooCommerce Email Discount Manager
|
||||
* Plugin URI: https://example.com/
|
||||
* Plugin URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
|
||||
* Version: 1.3.1
|
||||
* Version: 1.4.0
|
||||
* Author: WEDSMP
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: wc-email-discount
|
||||
* Requires at least: 5.8
|
||||
* Requires PHP: 7.4
|
||||
* WC requires at least: 6.0
|
||||
* WC tested up to: 11.1.0
|
||||
* Requires Plugins: woocommerce
|
||||
*/
|
||||
|
||||
/*
|
||||
* "Update URI" is what makes updates work, and it is load-bearing in a second
|
||||
* way that is easy to miss: since WordPress 5.8, a plugin that declares one is
|
||||
* never asked about on wordpress.org. Remove the line and a .org plugin that
|
||||
* happens to share this slug could be offered to every shop running this one.
|
||||
* includes/class-updater.php reads it; nothing else configures the source.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'WC_EDM_VERSION', '1.3.1' );
|
||||
define( 'WC_EDM_VERSION', '1.4.0' );
|
||||
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
|
||||
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
@@ -62,6 +72,13 @@ final class WC_Email_Discount_Manager {
|
||||
*/
|
||||
public $audit_log = null;
|
||||
|
||||
/**
|
||||
* Update checker.
|
||||
*
|
||||
* @var WC_EDM_Updater|null
|
||||
*/
|
||||
public $updater = null;
|
||||
|
||||
/**
|
||||
* Class files that could not be loaded.
|
||||
*
|
||||
@@ -109,6 +126,16 @@ final class WC_Email_Discount_Manager {
|
||||
* Bootstrap once all plugins are loaded.
|
||||
*/
|
||||
public function init() {
|
||||
// Deliberately before the WooCommerce check. Everything else here
|
||||
// needs WooCommerce, but updates do not — and a shop that has
|
||||
// WooCommerce switched off for an afternoon must not quietly stop
|
||||
// receiving fixes for this plugin.
|
||||
$updater = WC_EDM_PLUGIN_DIR . 'includes/class-updater.php';
|
||||
if ( is_readable( $updater ) ) {
|
||||
require_once $updater;
|
||||
$this->updater = new WC_EDM_Updater();
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'WooCommerce' ) ) {
|
||||
add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user