diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..c692aae --- /dev/null +++ b/.distignore @@ -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 diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..7ecec7a --- /dev/null +++ b/.gitea/workflows/README.md @@ -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**. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..856cde1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/compat.yml b/.gitea/workflows/compat.yml new file mode 100644 index 0000000..b05a07a --- /dev/null +++ b/.gitea/workflows/compat.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..16ddb32 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +# Builds the plugin zip, proves it installs and works, and publishes it here. +# +# This copy is the one that matters: Gitea is where the update feed lives, so +# this is the run that decides whether shops are offered the new version. The +# .github copy does the same work and publishes to GitHub — deliberately not +# byte-identical, and ci/workflows-in-sync.sh knows that. +# +# Nothing is published before the tests have passed against the current +# WordPress and WooCommerce. A release that fails here is one that was never +# offered to anybody, which is the entire point of doing it in this order. + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish, e.g. v1.4.0' + required: true + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Which tag + id: tag + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + [ -n "$TAG" ] || { echo "Could not determine the tag."; exit 1; } + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + # ci/publish-release.sh re-fetches the tag as a ref before reading + # its message, because checkout overwrites that ref with the commit + # SHA and turns the annotated tag into a lightweight one. It needs + # the history to do that. + fetch-depth: 0 + + - name: The header version is the tag + run: | + HEADER=$(ci/version.sh) + TAG_VERSION="${{ steps.tag.outputs.version }}" + echo "plugin header: $HEADER" + echo "tag: $TAG_VERSION" + if [ "$HEADER" != "$TAG_VERSION" ]; then + echo "::error::WordPress compares the update feed against the plugin header and nothing else." + echo "::error::With the header at $HEADER and the tag at $TAG_VERSION, every shop that installs this update is offered it again immediately, forever." + exit 1 + fi + + - name: Static checks + run: ci/static.sh + + - name: It installs and works on the current WordPress and WooCommerce + env: + WP_VERSION: latest + WC_VERSION: latest + PHP_VERSION: '8.3' + run: ci/site-test.sh + + - name: Build the zip + run: ci/build-zip.sh + + - name: Publish, then read the feed back anonymously + env: + TAG: ${{ steps.tag.outputs.tag }} + GH_TOKEN: ${{ github.token }} + run: ci/publish-release.sh diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..8818591 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..856cde1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 0000000..b05a07a --- /dev/null +++ b/.github/workflows/compat.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bb82eb0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release + +# Builds the plugin zip, proves it installs and works, and publishes it here. +# +# This copy publishes to GitHub. The update feed shops read lives on Gitea, so +# the .gitea copy is the one that decides whether shops are offered the new +# version; this one keeps GitHub's releases in step. Deliberately not +# byte-identical, and ci/workflows-in-sync.sh knows that. +# +# Nothing is published before the tests have passed against the current +# WordPress and WooCommerce. A release that fails here is one that was never +# offered to anybody, which is the entire point of doing it in this order. + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish, e.g. v1.4.0' + required: true + +permissions: + contents: write + +jobs: + release: + # A release workflow publishes to whichever forge RUNS it. Gitea picks up + # .github/workflows only when .gitea/workflows does not exist — so without + # this gate, deleting that directory would quietly start publishing a + # second, differently-built release to Gitea on every tag, while GitHub + # looked fine. + if: github.server_url == 'https://github.com' + runs-on: ubuntu-latest + steps: + - name: Which tag + id: tag + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + [ -n "$TAG" ] || { echo "Could not determine the tag."; exit 1; } + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + # ci/publish-release.sh re-fetches the tag as a ref before reading + # its message, because checkout overwrites that ref with the commit + # SHA and turns the annotated tag into a lightweight one. It needs + # the history to do that. + fetch-depth: 0 + + - name: The header version is the tag + run: | + HEADER=$(ci/version.sh) + TAG_VERSION="${{ steps.tag.outputs.version }}" + echo "plugin header: $HEADER" + echo "tag: $TAG_VERSION" + if [ "$HEADER" != "$TAG_VERSION" ]; then + echo "::error::WordPress compares the update feed against the plugin header and nothing else." + echo "::error::With the header at $HEADER and the tag at $TAG_VERSION, every shop that installs this update is offered it again immediately, forever." + exit 1 + fi + + - name: Static checks + run: ci/static.sh + + - name: It installs and works on the current WordPress and WooCommerce + env: + WP_VERSION: latest + WC_VERSION: latest + PHP_VERSION: '8.3' + run: ci/site-test.sh + + - name: Build the zip + run: ci/build-zip.sh + + - name: Publish, then read the feed back anonymously + env: + TAG: ${{ steps.tag.outputs.tag }} + GH_TOKEN: ${{ github.token }} + run: ci/publish-release.sh diff --git a/.gitignore b/.gitignore index 437c3dc..284df6a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ vendor/ *.zip wc-credentials.json +build/ diff --git a/README.txt b/README.txt index 3946212..796bad2 100644 --- a/README.txt +++ b/README.txt @@ -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. diff --git a/ci/build-zip.sh b/ci/build-zip.sh new file mode 100755 index 0000000..f2a9862 --- /dev/null +++ b/ci/build-zip.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Build the installable plugin zip and check it is actually installable. +# +# Writes build/.zip, containing a single top-level / 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" diff --git a/ci/bump-tested-up-to.sh b/ci/bump-tested-up-to.sh new file mode 100755 index 0000000..f9f3365 --- /dev/null +++ b/ci/bump-tested-up-to.sh @@ -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[@]}")")" diff --git a/ci/check-textdomain.php b/ci/check-textdomain.php new file mode 100644 index 0000000..8855f23 --- /dev/null +++ b/ci/check-textdomain.php @@ -0,0 +1,129 @@ + [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 ); diff --git a/ci/dist-files.sh b/ci/dist-files.sh new file mode 100755 index 0000000..64836e6 --- /dev/null +++ b/ci/dist-files.sh @@ -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 diff --git a/ci/latest-prerelease.php b/ci/latest-prerelease.php new file mode 100644 index 0000000..446db94 --- /dev/null +++ b/ci/latest-prerelease.php @@ -0,0 +1,50 @@ +' ) ) { + echo $newest; +} diff --git a/ci/lib.sh b/ci/lib.sh new file mode 100644 index 0000000..3437f23 --- /dev/null +++ b/ci/lib.sh @@ -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" +} diff --git a/ci/plugin.conf b/ci/plugin.conf new file mode 100644 index 0000000..57631cc --- /dev/null +++ b/ci/plugin.conf @@ -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 "/
", 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" diff --git a/ci/publish-release.sh b/ci/publish-release.sh new file mode 100755 index 0000000..ba26bf0 --- /dev/null +++ b/ci/publish-release.sh @@ -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." diff --git a/ci/site-test.sh b/ci/site-test.sh new file mode 100755 index 0000000..0f6e5b2 --- /dev/null +++ b/ci/site-test.sh @@ -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" diff --git a/ci/static.sh b/ci/static.sh new file mode 100755 index 0000000..65a4079 --- /dev/null +++ b/ci/static.sh @@ -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 " ] \ + && { 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:-}" + [ "$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:-}, current release is ${current_wp:-}" +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:-}, current release is ${current_wc:-}" + 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:-}" +fi + +echo +if [ "$fails" != 0 ]; then + echo "$fails check(s) failed." + exit 1 +fi +echo "Static checks: green." diff --git a/ci/version.sh b/ci/version.sh new file mode 100755 index 0000000..49dccbe --- /dev/null +++ b/ci/version.sh @@ -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 diff --git a/ci/workflows-in-sync.sh b/ci/workflows-in-sync.sh new file mode 100755 index 0000000..1bdb471 --- /dev/null +++ b/ci/workflows-in-sync.sh @@ -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." diff --git a/docs/REUSE.md b/docs/REUSE.md new file mode 100644 index 0000000..f8dbb79 --- /dev/null +++ b/docs/REUSE.md @@ -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. diff --git a/docs/TESTING-AND-RELEASES.md b/docs/TESTING-AND-RELEASES.md new file mode 100644 index 0000000..fee13c4 --- /dev/null +++ b/docs/TESTING-AND-RELEASES.md @@ -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//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. diff --git a/includes/class-updater.php b/includes/class-updater.php new file mode 100644 index 0000000..c11319c --- /dev/null +++ b/includes/class-updater.php @@ -0,0 +1,242 @@ +host(); + + if ( ! $host ) { + return; + } + + add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 2 ); + + // Without this the old answer is served for up to six hours after an + // update, and the plugins screen keeps offering a version already + // installed. + add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 ); + } + + /** + * The repository this plugin updates from, as stated in its own header. + * + * @return string + */ + public static function update_uri() { + $data = get_file_data( WC_EDM_PLUGIN_FILE, array( 'uri' => 'Update URI' ) ); + + return isset( $data['uri'] ) ? trim( $data['uri'] ) : ''; + } + + /** + * Hostname of the update URI, or an empty string when there is none. + * + * @return string + */ + private function host() { + $host = wp_parse_url( self::update_uri(), PHP_URL_HOST ); + + return is_string( $host ) ? $host : ''; + } + + /** + * Answer WordPress's update check for this plugin. + * + * Always returns what the feed says, even when it is the version already + * installed: WordPress compares the versions itself, and a plugin that + * answers "no update" by returning false never appears in the no_update + * list — which is what the "Enable auto-updates" link on the plugins + * screen is built from. Staying silent here is how a plugin ends up + * unable to auto-update while looking perfectly configured. + * + * @param array|false $update Update offered so far. + * @param array $plugin_data Headers of the installed plugin. + * @return array|false + */ + public function offer_update( $update, $plugin_data ) { + $release = $this->latest_release(); + + if ( ! $release ) { + return $update; + } + + return array( + 'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ), + 'version' => $release['version'], + 'package' => $release['package'], + 'url' => $release['url'], + 'tested' => $release['tested'], + 'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '', + 'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '', + ); + } + + /** + * Drop the cached feed. + */ + public function forget() { + delete_transient( self::TRANSIENT ); + } + + /** + * Read the newest release from the repository. + * + * @return array|null { version, package, url, tested }, or null. + */ + private function latest_release() { + $cached = get_transient( self::TRANSIENT ); + + if ( is_array( $cached ) ) { + return $cached; + } + if ( 'none' === $cached ) { + // A failure is cached too, briefly. Otherwise an unreachable host + // turns every admin page load into a blocking HTTP request. + return null; + } + + $uri = self::update_uri(); + $api = $this->api_url( $uri ); + + if ( ! $api ) { + return null; + } + + $response = wp_remote_get( + $api, + array( + 'timeout' => 10, + 'headers' => array( 'Accept' => 'application/json' ), + // WordPress's default user agent is + // "WordPress/6.8; https://the-shop.example" — it would tell + // the update host which shops are running this plugin. This + // plugin does not report that anywhere, so it must not leak + // it here either. + 'user-agent' => 'wc-email-discount-manager updater', + ) + ); + + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS ); + return null; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $body ) || empty( $body['tag_name'] ) ) { + set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS ); + return null; + } + + $package = ''; + $tested = ''; + + foreach ( isset( $body['assets'] ) ? $body['assets'] : array() as $asset ) { + if ( empty( $asset['name'] ) || empty( $asset['browser_download_url'] ) ) { + continue; + } + if ( substr( $asset['name'], -4 ) === '.zip' ) { + $package = $asset['browser_download_url']; + } + } + + // The release notes carry the compatibility line the release workflow + // writes, so wp-admin can show what this version was actually tested + // against instead of what the previous one claimed. + if ( ! empty( $body['body'] ) && preg_match( '/^Tested up to:\s*(\S+)/mi', $body['body'], $m ) ) { + $tested = $m[1]; + } + + if ( '' === $package ) { + // A release with no zip attached is a half-finished release. + // Offering it would hand WordPress a download it cannot install. + set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS ); + return null; + } + + $release = array( + 'version' => ltrim( $body['tag_name'], 'vV' ), + 'package' => $package, + 'url' => ! empty( $body['html_url'] ) ? $body['html_url'] : $uri, + 'tested' => $tested, + ); + + set_transient( self::TRANSIENT, $release, self::TTL ); + + return $release; + } + + /** + * Turn the repository's web address into its "latest release" API address. + * + * https://gitea.example/owner/repo + * -> https://gitea.example/api/v1/repos/owner/repo/releases/latest + * + * Derived rather than configured, so the plugin header stays the single + * place that says where updates come from. + * + * @param string $uri Repository web address. + * @return string Empty when the address is not a repository address. + */ + private function api_url( $uri ) { + $parts = wp_parse_url( $uri ); + + if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || empty( $parts['path'] ) ) { + return ''; + } + + $segments = array_values( array_filter( explode( '/', $parts['path'] ) ) ); + + if ( count( $segments ) < 2 ) { + return ''; + } + + return sprintf( + '%s://%s/api/v1/repos/%s/%s/releases/latest', + $parts['scheme'], + $parts['host'], + rawurlencode( $segments[0] ), + rawurlencode( $segments[1] ) + ); + } +} diff --git a/tests/fixtures.php b/tests/fixtures.php new file mode 100644 index 0000000..2ce6412 --- /dev/null +++ b/tests/fixtures.php @@ -0,0 +1,146 @@ +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 +); diff --git a/tests/smoke.php b/tests/smoke.php new file mode 100644 index 0000000..dc858aa --- /dev/null +++ b/tests/smoke.php @@ -0,0 +1,292 @@ +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 : '?' +); diff --git a/woocommerce-email-discount-manager.php b/woocommerce-email-discount-manager.php index 3e73d10..737a9e9 100644 --- a/woocommerce-email-discount-manager.php +++ b/woocommerce-email-discount-manager.php @@ -1,23 +1,33 @@ updater = new WC_EDM_Updater(); + } + if ( ! class_exists( 'WooCommerce' ) ) { add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) ); return;