diff --git a/.gitattributes b/.gitattributes index cea7cbc..8b9511e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,20 @@ # Git attributes for release automation +# +# Anything marked `export-ignore` is stripped from `git archive` output, which +# is how the distributable plugin ZIP is built. This file is therefore the +# single source of truth for what ships in a release. The compiled `build/` +# directory is intentionally NOT ignored so it is included in release archives. -# Exclude development files from release archives /.github export-ignore /src export-ignore +/tests export-ignore /node_modules export-ignore /.gitignore export-ignore /.gitattributes export-ignore +/.wp-env.json export-ignore +/playwright.config.js export-ignore /package.json export-ignore /package-lock.json export-ignore /README.md export-ignore +/RELEASE.md export-ignore +/CLAUDE.md export-ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9e1676..3569ad4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,88 +1,107 @@ -name: Version and Release +name: Release + +# Builds the plugin, stamps the version into the source, then creates an +# immutable tag that already points at the built, versioned code. +# +# The tag is created exactly once and is never moved or force-pushed, so +# Packagist (which requires tags to be immutable) accepts it without conflict. +# +# Trigger this manually from the Actions tab and supply the version to cut. on: - release: - types: [created] + workflow_dispatch: + inputs: + version: + description: 'Version to release, without a leading "v" (e.g. 1.2.3)' + required: true + type: string + +concurrency: + group: release-${{ github.event.inputs.version }} + cancel-in-progress: false jobs: - version-and-release: - name: Update Version and Create Release Asset + release: + name: Build, tag and publish runs-on: ubuntu-latest permissions: contents: write - steps: - - name: Checkout code at tag - uses: actions/checkout@v4 + - name: Validate version input + run: | + VERSION="${{ github.event.inputs.version }}" + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Version must be in X.Y.Z format without a leading 'v'. Got: '$VERSION'" + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "TAG=v$VERSION" >> "$GITHUB_ENV" + + - name: Check out source + uses: actions/checkout@v5 with: - ref: ${{ github.event.release.tag_name }} + ref: main fetch-depth: 0 - - name: Get version from tag - id: get_version + - name: Ensure the tag does not already exist run: | - # Extract version from tag (remove 'v' prefix if present) - TAG_NAME="${{ github.event.release.tag_name }}" - VERSION="${TAG_NAME#v}" + if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "::error::Tag ${TAG} already exists. Tags are immutable — bump the version and try again." + exit 1 + fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - echo "Tag: $TAG_NAME" + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies and build + run: | + npm ci + npm run build - - name: Replace __VERSION__ in plugin file + - name: Stamp version into the plugin run: | - sed -i "s/__VERSION__/${{ steps.get_version.outputs.version }}/g" hm-query-loop.php - echo "Updated plugin header:" - cat hm-query-loop.php | grep "Version:" - echo "Updated version constant:" - cat hm-query-loop.php | grep "HM_QUERY_LOOP_VERSION" + sed -i "s/__VERSION__/${VERSION}/g" hm-query-loop.php + echo "Stamped plugin header / constant:" + grep -n "Version:\|HM_QUERY_LOOP_VERSION" hm-query-loop.php - - name: Commit version changes to tag + - name: Create the immutable release commit and tag run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" - # Add and commit the versioned file + # build/ is gitignored in source; force-add the compiled assets + # so the tagged tree is self-contained and ready to install. + git add -f build git add hm-query-loop.php - git commit -m "Update version to ${{ steps.get_version.outputs.version }}" + git commit -m "Release ${TAG}" - # Delete the old tag and create a new one pointing to the new commit - git tag -d "${{ steps.get_version.outputs.tag }}" - git tag -a "${{ steps.get_version.outputs.tag }}" -m "Release ${{ steps.get_version.outputs.tag }}" + git tag -a "${TAG}" -m "Release ${TAG}" - # Force push the updated tag - git push origin "${{ steps.get_version.outputs.tag }}" --force + # Push ONLY the tag (and the objects it reaches). It is created + # once and never force-pushed, satisfying Packagist's immutability. + git push origin "refs/tags/${TAG}" - - name: Create ZIP archive + - name: Build distribution ZIP run: | - mkdir -p release - - # Create a clean copy excluding dev files - rsync -av --exclude='.git' \ - --exclude='.github' \ - --exclude='node_modules' \ - --exclude='src' \ - --exclude='release' \ - --exclude='.gitignore' \ - --exclude='.gitattributes' \ - --exclude='package.json' \ - --exclude='package-lock.json' \ - --exclude='RELEASE.md' \ - ./ release/hm-query-loop/ - - # Create ZIP - cd release - zip -r "hm-query-loop-${{ steps.get_version.outputs.tag }}.zip" hm-query-loop/ - cd .. - - echo "Created: release/hm-query-loop-${{ steps.get_version.outputs.tag }}.zip" + mkdir -p dist + # git archive honours the export-ignore rules in .gitattributes, + # so the ZIP contents are defined in exactly one place and are + # guaranteed to match the tagged tree (build/ included). + git archive --format=zip \ + --prefix=hm-query-loop/ \ + -o "dist/hm-query-loop-${TAG}.zip" \ + "${TAG}" + echo "Created dist/hm-query-loop-${TAG}.zip" - - name: Upload ZIP to release - uses: softprops/action-gh-release@v1 + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.event.release.tag_name }} - files: | - release/hm-query-loop-${{ steps.get_version.outputs.tag }}.zip + tag_name: ${{ env.TAG }} + name: ${{ env.TAG }} + generate_release_notes: true + files: dist/hm-query-loop-${{ env.TAG }}.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/RELEASE.md b/RELEASE.md index 8440d51..cad489e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,136 +4,119 @@ This document describes the automated release process for the HM Query Loop plug ## Overview -The plugin uses GitHub Actions to automate versioning when you manually create a release. When you create a release in the GitHub UI, the workflow will: +Releases are cut by the **`Release`** GitHub Actions workflow +(`.github/workflows/release.yml`), triggered manually from the Actions tab. -1. Checkout the code at the tag you created -2. Replace `__VERSION__` placeholders with the actual version -3. Commit the versioned file back to the tag -4. Create a production-ready ZIP file (excluding dev files) -5. Upload the ZIP as a release asset +The workflow does everything *before* the tag exists, then creates the tag +once and never touches it again: -## Prerequisites +1. Validates the version you supplied (must be `X.Y.Z`). +2. Builds the production assets (`npm ci && npm run build`). +3. Stamps the version into `hm-query-loop.php` (replaces `__VERSION__`). +4. Commits the built assets + stamped version and **creates an annotated tag + `vX.Y.Z` pointing at that commit**. +5. Pushes the tag — once, never force-pushed. +6. Builds the distribution ZIP with `git archive` and publishes a GitHub + release with auto-generated notes. -Before creating a release, the `release` branch should already have: -- All code changes merged from `main` -- Built assets in the `build/` directory (from the build workflow) -- Passing tests +Because the tag is created already-built and already-versioned, it is +**immutable**. This is what Packagist requires: Packagist rejects tag updates, +so the tag must never be moved after it is first published. -## Creating a Release +## Why it works this way -### Step 1: Prepare the Release Branch +The previous process created the GitHub release (and tag) first, then tried to +*rewrite* the tag to stamp the version and add built assets via a force-push. +Packagist had already ingested the original tag and refused the update, because +tags are expected to be immutable. Building before tagging removes the need to +ever move a tag. -Make sure the `release` branch is up to date with built assets: +## Creating a release -```bash -git checkout release -git pull origin release -``` - -The `release` branch should already contain built files in the `build/` directory from the build workflow. - -### Step 2: Create a Release in GitHub UI +1. Make sure `main` is green and contains the code you want to ship. +2. Go to **Actions → Release → Run workflow**. +3. Enter the version **without** a leading `v` (e.g. `1.2.3`). +4. Run it. -1. Go to your GitHub repository -2. Click on the "Releases" tab -3. Click "Draft a new release" -4. Fill in the release form: - - **Tag**: Create a new tag (e.g., `v1.2.3`) - - **Target**: Select the `release` branch - - **Title**: Release name (e.g., "v1.2.3") - - **Description**: Release notes (what's new, bug fixes, etc.) -5. Click "Publish release" +The workflow will fail fast if the tag already exists — tags are immutable, so +bump the version instead of trying to re-release. -**Important:** The tag must follow the format `vX.Y.Z` (e.g., `v1.2.3`) +## Version numbering -### Step 3: Monitor the Workflow +Follow [Semantic Versioning](https://semver.org/): -1. After publishing, go to the "Actions" tab -2. Watch the "Version and Release" workflow run -3. The workflow will: - - Update the version in the plugin file - - Update the tag to point to the versioned commit - - Create and upload a ZIP file +- **Major** (1.0.0 → 2.0.0): Breaking changes +- **Minor** (1.0.0 → 1.1.0): New features, backwards compatible +- **Patch** (1.0.0 → 1.0.1): Bug fixes, backwards compatible -### Step 4: Verify the Release +The version on `main` is always the literal placeholder `__VERSION__`; the real +number only ever exists inside a release tag. This keeps `main` free of version +churn and guarantees the version in a tag matches the tag name. -1. Go back to the "Releases" page -2. Your release should now have a ZIP file attached -3. Download and test the ZIP to ensure it works correctly +## What gets released -## Version Numbering +The ZIP is produced with `git archive`, which honours the `export-ignore` +rules in `.gitattributes`. That file is the **single source of truth** for what +ships. Currently included: -Follow [Semantic Versioning](https://semver.org/): +- `hm-query-loop.php` (with the version stamped in) +- `build/` (compiled assets) +- `inc/` +- `composer.json` -- **Major version** (1.0.0 → 2.0.0): Breaking changes -- **Minor version** (1.0.0 → 1.1.0): New features, backwards compatible -- **Patch version** (1.0.0 → 1.0.1): Bug fixes, backwards compatible +Everything marked `export-ignore` in `.gitattributes` (dev tooling, sources, +tests, docs) is excluded. To change what ships, edit `.gitattributes` — nothing +in the workflow needs to change. -## What Gets Released +## Packagist -The GitHub Action creates a ZIP file containing: +Packagist publishes new tags automatically (via the GitHub webhook / +auto-update). Since every `vX.Y.Z` tag is immutable and self-contained, no +manual intervention or tag rewriting is ever required. If a release was wrong, +**publish a new patch version** rather than attempting to move a tag. -- `hm-query-loop.php` (with version replaced) -- `build/` directory (compiled assets) -- Any other necessary plugin files +## The rolling `release` branch (optional) -The following are **excluded** from releases: -- `.git/` directory -- `.github/` directory -- `node_modules/` -- `src/` (source files) -- `package.json` and `package-lock.json` -- Development documentation (`README.md`) +`.github/workflows/build-and-release.yml` keeps a `release` branch in sync with +`main` plus committed `build/` assets on every push to `main`. This is useful +for installing the latest built code directly from a branch, but it is **not** +part of cutting a tagged release — the `Release` workflow builds fresh from +`main`. The `release` branch carries the `__VERSION__` placeholder and is not a +versioned artifact. ## Troubleshooting ### "Tag already exists" -If you need to recreate a release: - -1. Delete the existing release in GitHub UI -2. Delete the tag locally and remotely: - ```bash - git tag -d v1.2.3 - git push origin :refs/tags/v1.2.3 - ``` -3. Create a new release with the same or different version - -### Workflow fails - -Check the workflow logs in the GitHub Actions tab. Common issues: -- Missing `build/` directory on the release branch -- Incorrect tag format (must be `vX.Y.Z`) -- Permissions issues (workflow needs `contents: write`) - -### ZIP file not uploaded - -If the ZIP file isn't attached to the release: -1. Check the workflow logs for errors -2. Make sure the workflow completed successfully -3. The ZIP file should be named `hm-query-loop-vX.Y.Z.zip` - -## Manual Release (Fallback) - -If the automated process fails, you can create a release manually: - -1. Checkout the tag: `git checkout v1.2.3` -2. Replace `__VERSION__` in `hm-query-loop.php` with the actual version -3. Make sure `build/` directory exists with compiled assets -4. Create a ZIP file excluding dev files: - ```bash - zip -r hm-query-loop-v1.2.3.zip . \ - -x "*.git*" \ - -x "node_modules/*" \ - -x "src/*" \ - -x "package*.json" - ``` -5. Upload the ZIP to the GitHub release - -## Notes - -- The `release` branch should be kept built with assets in `build/` -- Tags are created manually through GitHub UI, not automatically -- The workflow updates the tag to point to versioned code -- You can create multiple releases from the same branch -- The workflow requires `contents: write` permission to update tags +This is by design — tags are immutable. Bump to the next version and run the +workflow again. + +### Build fails + +Check the workflow logs. The build runs `npm ci && npm run build`; make sure +`main` builds cleanly and `package-lock.json` is committed. + +### ZIP missing files / contains dev files + +Adjust the `export-ignore` entries in `.gitattributes`. The ZIP is generated +straight from the tagged tree via `git archive`, so it always matches what is +in the tag. + +## Manual release (fallback) + +If you ever need to cut a release by hand: + +```bash +# from a clean checkout of the commit you want to ship +npm ci && npm run build +sed -i "s/__VERSION__/1.2.3/g" hm-query-loop.php +git add -f build hm-query-loop.php +git commit -m "Release v1.2.3" +git tag -a v1.2.3 -m "Release v1.2.3" +git push origin refs/tags/v1.2.3 # push the tag ONCE; never force-push + +# package it +git archive --format=zip --prefix=hm-query-loop/ -o hm-query-loop-v1.2.3.zip v1.2.3 +``` + +Then attach the ZIP to a GitHub release created from the tag.