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..54801bf 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,7 @@ namespace HM\Button_Icon; -const VERSION = '1.1.2'; +const VERSION = '__VERSION__'; /** * The plugin's own directory, with a trailing slash.