From 32de45cc70fa42d671ffa0e7a7fff21b89b6a342 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Mon, 21 Sep 2026 15:06:38 +0100 Subject: [PATCH 1/2] Build and version a release before tagging it Tags carried no built assets. build/ is gitignored, so both the GitHub source archive and a Composer install from a tag gave a plugin that loads and renders nothing, and only the zip attached to the release worked. The old workflow could not fix that from where it ran: it fired on a tag that already existed, so anything it produced landed beside the tag rather than in it. Invert the order, as humanmade/hm-query-loop does. The Release workflow is now dispatched by hand with a version. It builds, stamps that version into the plugin, commits the built assets, and creates the tag on that commit, pushing the tag alone so main is untouched. A tag is therefore installable as it stands, through Composer as well as the zip, and is written once and never moved, which is what Packagist needs. The version follows from the same inversion. main carries the literal __VERSION__ in the header and the VERSION constant, and the real number exists only inside a tag, so the two cannot disagree and there is no bump commit to forget. The old tag-versus-header check has nothing left to check and goes. The zip is now git archive of the tag, so .gitattributes decides what ships instead of a cp list in the workflow. src/ stays in it: the tagged tree can then be rebuilt from itself. CI config, the PHPCS config and composer.lock are the only things dropped. build-and-release.yml is the other half of the pattern, keeping a release branch at main plus a built build/ for sites that install from a branch. It is not part of cutting a release and carries the placeholder, so it is not a versioned artifact. Co-Authored-By: Claude Opus 5 --- .gitattributes | 14 ++++ .github/workflows/build-and-release.yml | 42 ++++++++++ .github/workflows/release.yml | 107 ++++++++++++++++-------- CLAUDE.md | 16 ++-- README.md | 49 ++++++++--- button-block-icon.php | 10 ++- 6 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/build-and-release.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..12dbb36 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# What a release carries. +# +# The distribution zip is `git archive` of the tag, so the `export-ignore` +# rules below are the only place that decides what is left out of it. `build/` +# is deliberately not ignored: the release tag commits the compiled assets, and +# a plugin without them renders no icon. `src/` is kept too, so the tagged tree +# can be rebuilt from itself. + +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/CLAUDE.md export-ignore +/composer.lock export-ignore +/phpcs.xml.dist export-ignore diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml new file mode 100644 index 0000000..1f0f0c5 --- /dev/null +++ b/.github/workflows/build-and-release.yml @@ -0,0 +1,42 @@ +name: Production build + +# Keeps a `release` branch that is main plus the compiled `build/` directory, +# rebuilt on every push to main. It is there for sites that install the plugin +# from a branch rather than a tag, and it is not part of cutting a release: the +# Release workflow always builds fresh from main. The branch carries the +# `__VERSION__` placeholder, so it is not a versioned artifact. + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + release-branch: + name: Update the release branch + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Setup Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '22' + cache: 'npm' + + - name: Merge and build + uses: humanmade/hm-github-actions/.github/actions/build-to-release-branch@04c32a93e52ae987095f144105745a501d6207c8 # v0.2.0 + with: + source_branch: main + release_branch: release + built_asset_paths: build + build_script: | + npm ci + npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eaa01c2..ca7d0d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,30 +1,56 @@ name: Release +# Builds the assets and stamps the version first, then creates the tag on top +# of the result, so a tag always points at code that is already built and +# already says its own version. The tag is written once and never moved, which +# is what Composer's VCS repositories and Packagist expect of a tag. +# +# Run this from the Actions tab and give it the version to cut. + on: - push: - tags: - - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version to release, without a leading "v" (e.g. 1.2.3)' + required: true + type: string + +concurrency: + group: release-${{ inputs.version }} + cancel-in-progress: false jobs: release: - name: Build and attach the plugin zip + name: Build, tag and publish runs-on: ubuntu-latest permissions: contents: write steps: + - name: Check the version input + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if ! printf '%s' "${INPUT_VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Version must be X.Y.Z with no leading 'v'. Got: '${INPUT_VERSION}'" + exit 1 + fi + echo "VERSION=${INPUT_VERSION}" >> "$GITHUB_ENV" + echo "TAG=v${INPUT_VERSION}" >> "$GITHUB_ENV" + - name: Checkout uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: main + fetch-depth: 0 - # The tag is what everyone installs, so it has to agree with the two - # places the version is written. `VERSION` is also the cache buster the - # stylesheets fall back to, so a stale one is not only cosmetic. - - name: Check the tag matches the plugin version + # Tags are immutable here, so fail before anything is built rather than + # after, and before a half-made release exists to clean up. + - name: Check the tag is free run: | - VERSION="${GITHUB_REF_NAME#v}" - grep -q "^ \* Version: ${VERSION}$" button-block-icon.php \ - || { echo "Plugin header does not say ${VERSION}"; exit 1; } - grep -q "^const VERSION = '${VERSION}';$" button-block-icon.php \ - || { echo "VERSION constant does not say ${VERSION}"; exit 1; } + if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "::error::Tag ${TAG} already exists. Bump the version instead of moving it." + exit 1 + fi - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -35,33 +61,46 @@ jobs: - name: Install Node dependencies run: npm ci - # build/ is not committed, so the source archives GitHub generates carry - # no assets. This zip is the only artifact that installs and works. - name: Build run: npm run build + # main carries the literal `__VERSION__` in both places, so there is no + # version to disagree with the tag until this writes one. + - name: Stamp the version into the plugin + run: | + sed -i "s/__VERSION__/${VERSION}/g" button-block-icon.php + grep -n "Version:\|const VERSION" button-block-icon.php + + - name: Create the release commit and tag + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + # build/ is gitignored in the source tree, so force-add it: the whole + # point of the tag is that it is installable as it stands. + git add -f build + git add button-block-icon.php + git commit -m "Release ${TAG}" + git tag -a "${TAG}" -m "Release ${TAG}" + + # Push the tag and the objects it reaches, and nothing else. main is + # left where it was. + git push origin "refs/tags/${TAG}" + + # git archive honours the export-ignore rules in .gitattributes, so what + # ships is defined in one place and cannot drift from the tagged tree. - name: Package run: | - VERSION="${GITHUB_REF_NAME#v}" - mkdir -p dist/button-block-icon - cp button-block-icon.php README.md SECURITY.md LICENSE composer.json dist/button-block-icon/ - cp -R inc build dist/button-block-icon/ - cd dist && zip -rq "../button-block-icon-${VERSION}.zip" button-block-icon + mkdir -p dist + git archive --format=zip \ + --prefix=button-block-icon/ \ + -o "dist/button-block-icon-${VERSION}.zip" \ + "${TAG}" - # A release cut by hand already exists, so upload into it rather than - # failing; a tag pushed on its own gets a draft to write notes in. - - name: Attach to the release + - name: Publish the release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${GITHUB_REF_NAME#v}" - ZIP="button-block-icon-${VERSION}.zip" - - if gh release view "${GITHUB_REF_NAME}" > /dev/null 2>&1; then - gh release upload "${GITHUB_REF_NAME}" "${ZIP}" --clobber - else - gh release create "${GITHUB_REF_NAME}" "${ZIP}" \ - --draft \ - --title "${VERSION}" \ - --generate-notes - fi + gh release create "${TAG}" "dist/button-block-icon-${VERSION}.zip" \ + --title "${VERSION}" \ + --generate-notes diff --git a/CLAUDE.md b/CLAUDE.md index 0594e68..27384c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,17 +22,21 @@ composer format # PHPCBF ``` The package is not on public Packagist. `README.md` documents the VCS -`repositories` entry a consuming site needs, and the fact that Composer installs -no built assets. +`repositories` entry a consuming site needs. A tagged version installs with its +built assets, since the tag commits `build/`; a `dev-main` install does not. There are no automated tests, no test runner and no `wp-env` setup. CI (`.github/workflows/ci.yml`) runs `lint:js`, `lint:css`, `build`, then `composer lint`. Do not invent a test command. -Releases come from pushing a `v` tag, which runs -`.github/workflows/release.yml`: it fails the tag unless both the plugin header -and the `VERSION` constant say the same version, then builds and attaches the -installable zip. Bump both strings in the same commit the tag points at. +Releases are cut by dispatching `.github/workflows/release.yml` from the +Actions tab with a version. It builds, stamps that version over the +`__VERSION__` placeholder in the plugin header and the `VERSION` constant, +commits the built `build/` with it, and tags that commit. Do not bump a version +by hand and do not push a `v` tag: `main` keeps the placeholder, the number +lives only in the tag, and a tag that already exists is refused rather than +moved. What the zip carries is set by `export-ignore` in `.gitattributes`, not +by the workflow. Every enqueue in `inc/assets.php` is guarded on `is_readable`, so an unbuilt checkout renders no icon and raises no error. Check `build/` exists before diff --git a/README.md b/README.md index 010ecf4..4ab91d8 100644 --- a/README.md +++ b/README.md @@ -245,10 +245,13 @@ in `wp-content/plugins/button-block-icon` unless the root `composer.json` overrides `installer-paths`. The site needs PHP 8.2 or later, and WordPress 7.1 or later for the Icons API. -Composer does not build the editor assets. A checkout installed this way still -needs the build below, either run in place or run in CI and shipped with the -deploy. Without it the plugin loads and renders no icon, since every enqueue is -guarded on the built file being there. +A tagged version carries its own built assets: the release workflow commits +`build/` into the tag before it is created, so `composer require` on a version +constraint gives you a plugin that works as installed. A `dev-main` install +does not, since `build/` is not committed on `main`. That one needs the build +below, either run in place or run in CI and shipped with the deploy; without it +the plugin loads and renders no icon, since every enqueue is guarded on the +built file being there. Or clone it into `wp-content/plugins/` and run the build below. @@ -277,11 +280,33 @@ CSS; `composer lint` runs PHPCS against the Human Made standard. ## Releases -Bump the version in the plugin header and in the `VERSION` constant, then push a -`v` tag. `.github/workflows/release.yml` refuses a tag that disagrees -with either of them, builds the assets, and attaches -`button-block-icon-.zip` to that tag's release, creating a draft if -there is not one already. - -That zip is the artifact to install. The source archives GitHub generates carry -no `build/`, since it is not committed, and a plugin without it renders no icon. +Releases are cut by the **Release** workflow +(`.github/workflows/release.yml`), run by hand from the Actions tab with the +version to ship, written `1.2.3` with no leading `v`. + +It builds the assets, writes that version over the `__VERSION__` placeholder in +`button-block-icon.php`, commits the built assets and the stamped file, and +tags *that* commit `v1.2.3`. Only the tag is pushed, so `main` stays where it +was. It then attaches `button-block-icon-1.2.3.zip` to the release. + +Two things follow from building before tagging rather than after. A tag is +already built and already versioned, so it installs as it stands, whether +through Composer or as the zip. And the tag is written once and never moved, +which is what Packagist requires — the workflow refuses a version whose tag +already exists, so a bad release is superseded by the next patch version rather +than rewritten. + +The version on `main` is always the literal `__VERSION__`, in both the plugin +header and the `VERSION` constant. The real number exists only inside a tag, +which is what keeps the two from ever disagreeing. + +The zip is `git archive` of the tag, so `.gitattributes` is the single place +that decides what ships. `build/` and `src/` are both in it; CI config, the +PHPCS config and `composer.lock` are not. To change what a release carries, +edit `.gitattributes` — the workflow needs no change. + +`.github/workflows/build-and-release.yml` separately keeps a `release` branch +in step with `main` plus a built `build/`, on every push to `main`. It is there +for installing the latest built code from a branch. It is not part of cutting a +release, and it carries the `__VERSION__` placeholder, so it is not a versioned +artifact. diff --git a/button-block-icon.php b/button-block-icon.php index 2e6ebc5..900270f 100644 --- a/button-block-icon.php +++ b/button-block-icon.php @@ -3,7 +3,7 @@ * Plugin Name: Button Block Icon * Plugin URI: https://github.com/humanmade/button-block-icon * Description: Puts an icon beside the label on core/button, chosen from a registered icon collection or uploaded as a one-off SVG. - * Version: 1.1.2 + * Version: __VERSION__ * Requires at least: 7.1 * Requires PHP: 8.2 * Author: Human Made Limited @@ -20,7 +20,13 @@ namespace HM\Button_Icon; -const VERSION = '1.1.2'; +/** + * The plugin version. + * + * Both this and the header say `__VERSION__` on main; the release workflow + * writes the real number into the tagged commit. + */ +const VERSION = '__VERSION__'; /** * The plugin's own directory, with a trailing slash. From 99b8b6761d3616819f3037bd4d31ea66a00a4117 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke <23417+roborourke@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:41:43 +0100 Subject: [PATCH 2/2] Update button-block-icon.php --- button-block-icon.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/button-block-icon.php b/button-block-icon.php index 900270f..54801bf 100644 --- a/button-block-icon.php +++ b/button-block-icon.php @@ -20,12 +20,6 @@ namespace HM\Button_Icon; -/** - * The plugin version. - * - * Both this and the header say `__VERSION__` on main; the release workflow - * writes the real number into the tagged commit. - */ const VERSION = '__VERSION__'; /**