diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5b7fbba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Everything below exists for development only and must never reach the plugin +# zip. `bin/build-plugin-zip.sh` builds with `git archive`, which honours these, +# so this file is the single list of what is developer-only. +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.phpcs.xml.dist export-ignore +/README.md export-ignore +/bin export-ignore +/composer.json export-ignore +/docs export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80de2de..a5a7e08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,22 @@ jobs: - name: WordPress coding standards run: composer run phpcs + package: + name: Plugin zip + runs-on: ubuntu-latest + steps: + # The build reads .gitattributes and the version headers out of the + # commit itself, so it needs real history rather than a snapshot. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The same script the release workflow runs. It fails on a mismatched + # version, a stray development file or a wrong top-level directory, so + # packaging breaks in a pull request rather than at release time. + - name: Build the plugin zip + run: bin/build-plugin-zip.sh "$GITHUB_SHA" + test: name: PHP ${{ matrix.php }} / WP ${{ matrix.wp }} runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..594b66b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +# Publishes citecue.zip — an archive that unpacks to citecue/ and contains only +# the runtime files — as a release asset, so that installing the plugin is +# "Plugins → Add New → Upload" rather than "download the source, rename the +# folder, delete the tests". + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + release: + name: Build and publish the plugin zip + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + # The build reads .gitattributes and the version strings out of the ref + # itself, so it needs the tag object, not just a shallow snapshot. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build the plugin zip + run: bin/build-plugin-zip.sh "$GITHUB_SHA" + + # A tag that disagrees with the plugin header would ship an update + # WordPress cannot reason about; catch it before the asset is public. + - name: Check the tag matches the plugin version + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + header=$(sed -n 's/^[[:space:]]*\*[[:space:]]*Version:[[:space:]]*\([^[:space:]]*\).*/\1/p' citecue.php | head -n 1) + if [ "$tag" != "$header" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match plugin version $header" + exit 1 + fi + + - name: Upload the zip as a workflow artifact + uses: actions/upload-artifact@v4 + with: + name: citecue-plugin-zip + path: dist/citecue.zip + if-no-files-found: error + + - name: Attach the zip to the release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" dist/citecue.zip --clobber + else + gh release create "$GITHUB_REF_NAME" dist/citecue.zip \ + --title "$GITHUB_REF_NAME" \ + --generate-notes + fi diff --git a/.gitignore b/.gitignore index 4aaf3f4..866a7f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.zip +/dist/ .DS_Store node_modules/ vendor/ diff --git a/README.md b/README.md index 66ff6da..f59fb14 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor ## Setup -1. Install and activate the plugin (upload this repo as a zip or drop it into `wp-content/plugins/`). +1. Download `citecue.zip` from the [latest release](https://github.com/citecue/wordpress-plugin/releases/latest), then install it under **Plugins → Add New → Upload Plugin** and activate it. 2. Open **Settings → CiteCue** and click **Connect to CiteCue**. 3. Confirm the project for this site in CiteCue. You are redirected back, and the plugin checks itself. @@ -196,6 +196,18 @@ The worst case for an AI crawler is one 3 s wait per minute. For a human visitor Plain PHP ≥ 7.4, no build step. Repo root is the plugin root, so the checkout can be symlinked straight into `wp-content/plugins/`. +### Releasing + +GitHub's **Download ZIP** button is not an install path: it produces `wordpress-plugin-main.zip`, which unpacks to `wordpress-plugin-main/` and carries the tests and Composer files with it. WordPress keys a plugin by its directory name, so installs have to come from the release asset instead. + +```bash +bin/build-plugin-zip.sh # writes dist/citecue.zip from HEAD +``` + +The script archives tracked files only, honouring the `export-ignore` rules in `.gitattributes`, so nothing untracked (a `vendor/`, a stray `.env`) can be swept in. It refuses to build unless `citecue.php`'s `Version:` header, `CITECUE_VERSION` and `readme.txt`'s `Stable tag:` all agree, and it checks the result unpacks to a single `citecue/` directory. CI runs the same script on every pull request. + +To publish: bump those three version strings, then push a `vX.Y.Z` tag. The release workflow rebuilds the zip, fails if the tag disagrees with the plugin header, and attaches `citecue.zip` to the GitHub release. + ### Tests The suite is WordPress integration tests: real options, transients, REST requests and query conditionals, with the CiteCue API faked at the `wp_remote_get` layer (`tests/includes/class-citecue-http-mock.php`) so no test ever touches the network. WordPress core and its test library both come from Composer — there is nothing to download by hand. diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh new file mode 100755 index 0000000..a059a41 --- /dev/null +++ b/bin/build-plugin-zip.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Builds the installable WordPress plugin zip. +# +# GitHub's own "Download ZIP" button produces wordpress-plugin-main.zip, which +# unpacks to wordpress-plugin-main/ and carries the tests, CI config and +# Composer files with it. WordPress keys a plugin by its directory name — that +# name ends up in the plugins list, in update checks and in every support +# thread — so the distributed archive has to unpack to citecue/ and contain +# only the files the plugin loads at runtime. +# +# `git archive` gives both properties for free: --prefix sets the directory +# name, and it only ever reads tracked files honouring the export-ignore rules +# in .gitattributes, so an untracked vendor/, .env or editor backup can never +# be swept into a release. +# +# Usage: bin/build-plugin-zip.sh [ref] (ref defaults to HEAD) + +set -euo pipefail + +SLUG=citecue +REF=${1:-HEAD} + +ROOT=$(git rev-parse --show-toplevel) +OUT_DIR="$ROOT/dist" +OUT="$OUT_DIR/$SLUG.zip" + +if ! git rev-parse --verify --quiet "$REF^{commit}" >/dev/null; then + echo "error: '$REF' is not a commit in this repository" >&2 + exit 1 +fi + +# Read the version out of the ref being built, not the working tree, so that +# building an old tag reports that tag's version. +show() { git show "$REF:$1"; } + +header_version=$(show citecue.php | sed -n 's/^[[:space:]]*\*[[:space:]]*Version:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -n 1) +const_version=$(show citecue.php | sed -n "s/^[[:space:]]*define([[:space:]]*'CITECUE_VERSION'[[:space:]]*,[[:space:]]*'\([^']*\)'.*/\1/p" | head -n 1) +readme_version=$(show readme.txt | sed -n 's/^Stable tag:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -n 1) + +if [ -z "$header_version" ]; then + echo "error: could not read the Version: header from citecue.php" >&2 + exit 1 +fi + +# A plugin whose three version strings disagree updates incorrectly in the +# wild, and the mismatch is invisible until someone tries to upgrade. +if [ "$header_version" != "$const_version" ] || [ "$header_version" != "$readme_version" ]; then + echo "error: version mismatch in $REF" >&2 + echo " citecue.php header: ${header_version:-}" >&2 + echo " CITECUE_VERSION: ${const_version:-}" >&2 + echo " readme.txt Stable tag: ${readme_version:-}" >&2 + exit 1 +fi + +if [ "$REF" = HEAD ] && ! git diff --quiet HEAD -- .; then + echo "warning: the working tree has uncommitted changes; building HEAD as committed" >&2 +fi + +mkdir -p "$OUT_DIR" +rm -f "$OUT" +git archive --format=zip -9 --prefix="$SLUG/" --output="$OUT" "$REF" + +# Guard the two properties the whole exercise is about: one top-level +# directory, correctly named, and no development files inside it. +entries=$(unzip -Z1 "$OUT") +stray_root=$(printf '%s\n' "$entries" | grep -v "^$SLUG/" || true) +if [ -n "$stray_root" ]; then + echo "error: archive has entries outside $SLUG/:" >&2 + printf '%s\n' "$stray_root" >&2 + exit 1 +fi + +stray_dev=$(printf '%s\n' "$entries" | grep -E "^$SLUG/(tests/|bin/|docs/|\.github/|composer\.|phpunit|\.phpcs|README\.md)" || true) +if [ -n "$stray_dev" ]; then + echo "error: development files leaked into the archive:" >&2 + printf '%s\n' "$stray_dev" >&2 + exit 1 +fi + +for required in "$SLUG/citecue.php" "$SLUG/readme.txt" "$SLUG/uninstall.php"; do + if ! printf '%s\n' "$entries" | grep -qx "$required"; then + echo "error: $required is missing from the archive" >&2 + exit 1 + fi +done + +echo "Built $OUT (version $header_version, $(printf '%s\n' "$entries" | grep -cv '/$') files)" +printf '%s\n' "$entries" | sed 's/^/ /' diff --git a/readme.txt b/readme.txt index d8bce07..2dc7bd2 100644 --- a/readme.txt +++ b/readme.txt @@ -23,7 +23,7 @@ Requires a CiteCue account. == Installation == -1. Upload the plugin to `/wp-content/plugins/` and activate it. +1. In WordPress, go to Plugins → Add New → Upload Plugin, choose `citecue.zip` and activate it. 2. Go to Settings → CiteCue and click "Connect to CiteCue". 3. Confirm the project for this site in CiteCue. You are redirected back and the plugin checks itself. 4. Add and generate optimized pages on CiteCue's Auto-Fix page.