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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/check-version-bump.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
#
# Fails when a branch changes something that ships without raising the version.
#
# tests/test-version.php asserts the four version locations agree; it cannot notice that
# all four agree on the *old* number. Bumping nothing therefore passes every other check,
# and the mistake surfaces only when a release is cut — on a merged branch, by hand.
# See CLAUDE.md: "Versioning".
#
# Usage: check-version-bump.sh [base-ref] (default: main; CI passes origin/<base>)
# Set ALLOW_UNVERSIONED=1 to pass anyway, for a branch that deliberately ships unversioned.

set -euo pipefail

readonly BASE="${1:-main}"
readonly PLUGIN_FILE='site-icon-fallback.php'

fail() {
printf 'error: %s\n' "$1" >&2
exit 1
}

read_version() {
sed -n 's/^ \* Version: *//p' | tr -d '[:space:]'
}

# A path ships unless it, or any directory above it, is export-ignored. The ancestor walk is
# the whole point: git archive prunes a directory before it looks inside, so `/tests` never
# matches tests/foo.php on its own. See CLAUDE.md: "The release branch is stripped".
ships() {
local path="$1" dir

if [ "$( git check-attr export-ignore -- "${path}" | sed 's/.*: //' )" = 'set' ]; then
return 1
fi

dir="$( dirname "${path}" )"
while [ "${dir}" != '.' ] && [ "${dir}" != '/' ]; do
if [ "$( git check-attr export-ignore -- "${dir}" | sed 's/.*: //' )" = 'set' ]; then
return 1
fi
dir="$( dirname "${dir}" )"
done

return 0
}

version_gt() {
local a1 a2 a3 b1 b2 b3
IFS=. read -r a1 a2 a3 <<< "$1"
IFS=. read -r b1 b2 b3 <<< "$2"

if [ "$(( a1 ))" -ne "$(( b1 ))" ]; then
[ "$(( a1 ))" -gt "$(( b1 ))" ]
return
fi
if [ "$(( a2 ))" -ne "$(( b2 ))" ]; then
[ "$(( a2 ))" -gt "$(( b2 ))" ]
return
fi
[ "$(( a3 ))" -gt "$(( b3 ))" ]
}

git rev-parse --verify --quiet "${BASE}" > /dev/null \
|| fail "base ref '${BASE}' not found — CI needs actions/checkout with fetch-depth: 0"

base_version="$( git show "${BASE}:${PLUGIN_FILE}" | read_version )"
head_version="$( read_version < "${PLUGIN_FILE}" )"

[ -n "${base_version}" ] || fail "no Version header on ${BASE}"
[ -n "${head_version}" ] || fail "no Version header in ${PLUGIN_FILE}"

# All three sources, because a branch mid-review has committed, modified and untracked work
# at once, and `git diff ${BASE}...HEAD` alone reports none of the last two. Reading only
# that is how this check would come to pass a branch it should fail.
changed="$( mktemp )"
shipping="$( mktemp )"
trap 'rm -f "${changed}" "${shipping}"' EXIT

{
git diff --name-only "${BASE}...HEAD"
git diff --name-only HEAD
git ls-files --others --exclude-standard
} | LC_ALL=C sort -u > "${changed}"

while IFS= read -r path; do
[ -n "${path}" ] || continue
if ships "${path}"; then
printf '%s\n' "${path}" >> "${shipping}"
fi
done < "${changed}"

if [ ! -s "${shipping}" ]; then
printf 'No change ships: nothing to version (still %s).\n' "${head_version}"
exit 0
fi

if [ "${ALLOW_UNVERSIONED:-0}" = '1' ]; then
printf 'ALLOW_UNVERSIONED=1: %d shipping change(s) accepted at %s.\n' \
"$( wc -l < "${shipping}" | tr -d ' ' )" "${head_version}"
exit 0
fi

if [ "${head_version}" = "${base_version}" ]; then
printf 'error: these changes ship, but the version is still %s:\n' "${head_version}" >&2
sed 's/^/ /' "${shipping}" >&2
printf 'Bump all four locations — see CLAUDE.md: "Versioning".\n' >&2
exit 1
fi

version_gt "${head_version}" "${base_version}" \
|| fail "version went backwards: ${BASE} is ${base_version}, this branch is ${head_version}"

printf 'Version raised %s -> %s for %d shipping change(s).\n' \
"${base_version}" "${head_version}" "$( wc -l < "${shipping}" | tr -d ' ' )"
46 changes: 46 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Tests

# Deliberately unfiltered by path, unlike php.yml. The suites assert across file types that
# do not look related — the version test reads readme.txt and site-icon-fallback.php, the
# strip test reads .gitattributes — so any paths list narrow enough to be worth having is
# also narrow enough to skip the change that breaks one of them. The whole run is seconds.
on:
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
tests:
name: Test suites
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# The version gate below diffs this branch against its base, which the default
# shallow fetch does not bring down.
fetch-depth: 0

- name: Set up PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.2'
coverage: none

# No install step: the suites are plain PHP and bash, and nothing in devDependencies
# is on their path. Adding `npm ci` here would cost more than the tests take to run.
- name: Run test suites
run: npm test

# Separate from the suites above: those are hermetic, this one judges the branch it
# runs on, so it belongs to the PR rather than to `npm test`.
- name: Check the version was raised
run: bash .github/check-version-bump.sh "origin/${{ github.base_ref }}"
52 changes: 49 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,62 @@ Change these only with the reasoning in mind — each one exists because the obv
- Comments explain *why*, particularly where a simpler-looking alternative is wrong.
- **Docblocks stay short: summary line, at most three lines of rationale, then the tags.** The *why* still belongs at the code, but a reader after the signature should not have to parse an essay to reach it. Reasoning that needs more room goes in the "Decisions that are load-bearing" section above, with a one-line pointer at the code — `See CLAUDE.md: "Content types are allow-listed too."` One copy of an argument cannot drift from the other.

## Versioning

**Bump the version once per branch, on the first change that ships — then never again on that branch.**

Four places carry it and all four move together:

| Where | Form |
| --- | --- |
| `site-icon-fallback.php:5` | ` * Version: x.y.z` |
| `site-icon-fallback.php:23` | `const VERSION = 'x.y.z';` |
| `readme.txt:7` | `Stable tag: x.y.z` |
| `readme.txt` changelog | a new `= x.y.z =` heading |

**Whether this branch has bumped already is answered by git, not by memory.** Read the version off `main` and compare:

```
git show main:site-icon-fallback.php | sed -n 's/^ \* Version: *//p'
```

Equal to the working tree means this branch has not bumped yet, so bump now and add the changelog heading. Different means the bump already happened on this branch — add to the existing changelog entry rather than opening a second one, and leave all four numbers alone. This is what keeps a five-commit branch from arriving at 0.1.5. Never bump on `main` itself: branch first, per the global protocol.

**A change that ships nothing gets no bump.** `.gitattributes` already defines what ships, so the question is decidable rather than a judgement call — if every changed path is `export-ignore`d (`tests/`, `.github/`, `AGENTS.md`, `docs/`, tooling config), the distributed plugin is byte-identical and there is no new version to name. Release tooling and test changes are the common case here.

**Do not answer that from `git diff main...HEAD`.** It reports only what is already on the branch as a commit, and a branch under review has modified and untracked work at the same time — the answer it gives is "nothing changed", which reads as "nothing ships" and is wrong in the direction of not bumping. Three sources or none:

```
{ git diff --name-only main...HEAD; # already on the branch
git diff --name-only HEAD; # modified, not yet recorded
git ls-files --others --exclude-standard; } # new, untracked
```

Classify each result with `git check-attr export-ignore`, **walking up the parents too** — `/tests` does not match `tests/foo.php`, the same trap `strip-dev-files.sh` exists for. `npm run check:version` does all of this; run it rather than reimplementing it.

Patch for fixes, minor for new behaviour, major for a break.

**Three gates enforce this, and each catches what the others cannot.**

| Gate | Catches |
| --- | --- |
| `.github/check-version-bump.sh` (PR) | A shipping change that raised nothing, or lowered the version |
| `tests/test-version.php` (PR) | The four locations disagreeing with each other |
| `tag-and-release.yml` (release) | A tag that does not match the `Version:` header |

The first two are the ones that matter, because they fail on a branch rather than at a release. Note what the second cannot see on its own: four locations agreeing on the *old* number is a passing state, so consistency checking alone never notices a bump that did not happen. Together the three pin the tag to all four locations and to a number strictly above `main`, which is why the release workflow needs no four-way check of its own.

## Commands

| Command | What it does |
| --- | --- |
| `composer install` | Installs `humanmade/coding-standards` (the only dependency) |
| `composer phpcs` | Lints `inc/`, `site-icon-fallback.php` and `uninstall.php` against the HM standard via `.phpcs.xml` |
| `composer phpcbf` | Auto-fixes what phpcs can |
| `npm test` | Runs both suites below |
| `npm run test:php` | `tests/test-routing.php` — plain PHP, no WordPress bootstrap |
| `npm run test:sh` | The shell suites — `test-nginx-installer.sh` drives the installer against temp files, `test-strip-dev-files.sh` drives the release strip against throwaway repositories |
| `npm test` | Runs both suites below. CI runs this on every PR via `tests.yml`, unfiltered by path |
| `npm run test:php` | The PHP suites, both plain PHP with no WordPress bootstrap — `test-routing.php` covers routing and streaming, `test-version.php` asserts the four version locations agree |
| `npm run test:sh` | The shell suites — `test-nginx-installer.sh` drives the installer against temp files, `test-strip-dev-files.sh` and `test-version-bump.sh` drive the release strip and the version gate against throwaway repositories |
| `npm run check:version` | Answers "does this branch ship a change that needs a version bump?" — judges the working tree, so it is not part of `npm test` |
| `wp site-icon-fallback status` | The plugin's own check: icon set, server, reachability, serve mode |
| `wp site-icon-fallback nginx-config` | Prints the snippet for this install |
| `npm run env:start` | Boots `@wordpress/env` on port 3031 with Query Monitor |
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"private": true,
"description": "A lightweight fallback that serves your Site Icon from the site root, reducing 404s.",
"scripts": {
"check:version": "bash .github/check-version-bump.sh",
"env:cli": "npx @wordpress/env run cli",
"env:db": "npx @wordpress/env run cli -- mysql -uroot -ppassword wordpress --host=mysql",
"env:destroy": "npx @wordpress/env destroy",
Expand All @@ -12,8 +13,8 @@
"format": "wp-scripts format",
"lint:php": "composer phpcs",
"test": "npm run test:php && npm run test:sh",
"test:php": "php tests/test-routing.php",
"test:sh": "bash tests/test-nginx-installer.sh && bash tests/test-strip-dev-files.sh"
"test:php": "php tests/test-routing.php && php tests/test-version.php",
"test:sh": "bash tests/test-nginx-installer.sh && bash tests/test-strip-dev-files.sh && bash tests/test-version-bump.sh"
},
"author": "Human Made Limited",
"license": "GPL-2.0-or-later",
Expand Down
7 changes: 6 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tags: favicon, site icon, apple-touch-icon, safari, ios
Requires at least: 6.7
Tested up to: 7.0
Requires PHP: 8.2
Stable tag: 0.1.0
Stable tag: 0.1.1
License: GPL-2.0-or-later
License URI: http://www.gnu.org/licenses/gpl-2.0.txt

Expand Down Expand Up @@ -96,5 +96,10 @@ The root paths return a 404. Notably this is *not* what core does for `/favicon.

== Changelog ==

= 0.1.1 =
* Releases now include an installable site-icon-fallback.zip, instead of only the generated source archive.
* That archive extracts to a stable site-icon-fallback directory, so a manual upload no longer renames the plugin folder on every release.
* Development files are excluded from the release branch as well as from tagged archives.

= 0.1.0 =
* Initial release.
4 changes: 2 additions & 2 deletions site-icon-fallback.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* Plugin Name: Site Icon Fallback
* Description: A lightweight fallback that serves your Site Icon from the site root, reducing 404s.
* Version: 0.1.0
* Version: 0.1.1
* Requires at least: 6.7
* Requires PHP: 8.2
* Author: Human Made
Expand All @@ -20,7 +20,7 @@
exit;
}

const VERSION = '0.1.0';
const VERSION = '0.1.1';

/**
* Absolute path to this file.
Expand Down
Loading