Merge pull request #1 from kristianwind/ci-and-updates
Test against the newest WordPress and WooCommerce, and ship updates automatically
This commit is contained in:
+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,91 @@
|
||||
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 tools this release needs are present
|
||||
# Checked first, because everything below takes about ten minutes and
|
||||
# discovering that the runner image has no python3 at the publishing
|
||||
# step means doing all of it again.
|
||||
run: |
|
||||
missing=
|
||||
for t in git curl python3 tar; do
|
||||
command -v "$t" >/dev/null 2>&1 || missing="$missing $t"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::This runner is missing:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "git, curl, python3 and tar are all present."
|
||||
|
||||
- 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,97 @@
|
||||
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 tools this release needs are present
|
||||
# Checked first, because everything below takes about ten minutes and
|
||||
# discovering that the runner image has no python3 at the publishing
|
||||
# step means doing all of it again.
|
||||
run: |
|
||||
missing=
|
||||
for t in git curl python3 tar; do
|
||||
command -v "$t" >/dev/null 2>&1 || missing="$missing $t"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::This runner is missing:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "git, curl, python3 and tar are all present."
|
||||
|
||||
- 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,257 @@
|
||||
<?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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoised update URI, so the plugin file is read at most once a request.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
private static $uri = null;
|
||||
|
||||
/**
|
||||
* The repository this plugin updates from, as stated in its own header.
|
||||
*
|
||||
* Read from the header rather than a constant so that there is exactly one
|
||||
* place saying where updates come from — but memoised, because the filter
|
||||
* is registered on every request a shop serves, not just in wp-admin, and
|
||||
* get_file_data() opens and scans the file each time it is asked.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function update_uri() {
|
||||
if ( null === self::$uri ) {
|
||||
$data = get_file_data( WC_EDM_PLUGIN_FILE, array( 'uri' => 'Update URI' ) );
|
||||
self::$uri = isset( $data['uri'] ) ? trim( $data['uri'] ) : '';
|
||||
}
|
||||
|
||||
return self::$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