From 128bfa2fadb619b815e4ee08398f41e839f4de30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 12:57:51 +0000 Subject: [PATCH] Build releases before tagging so tags are immutable The release workflow previously created the tag (via the GitHub release UI) and then force-pushed a rewritten tag to stamp the version and add built assets. Packagist requires tags to be immutable and rejected the update. Rework the Release workflow to build and stamp the version BEFORE the tag exists, then create the vX.Y.Z tag once at the built+versioned commit and push it without force. Generate the ZIP with git archive so .gitattributes is the single source of truth for release contents. --- .gitattributes | 11 +- .github/workflows/release.yml | 141 +++++++++++++----------- RELEASE.md | 199 ++++++++++++++++------------------ 3 files changed, 181 insertions(+), 170 deletions(-) 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.