diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed0d6207..4cd88440 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,27 @@ name: Release +# Publishes a GitHub Release for the version in bibliography-builder.php when +# no release for it exists yet, then dispatches the WordPress.org deploy. It +# runs when: +# - a tag like v1.11.0 is pushed (the tag must match the version); +# - main changes bibliography-builder.php or this workflow, so merging a +# version bump releases it; +# - it is run by hand (workflow_dispatch). +# The plugin header, readme.txt Stable tag, package.json and block.json must +# all name the same version. The release body is that version's CHANGELOG.md +# section, followed by GitHub's generated pull-request list. An existing +# release is never changed. + on: push: tags: - 'v*' + branches: + - main + paths: + - 'bibliography-builder.php' + - '.github/workflows/release.yml' + workflow_dispatch: permissions: contents: write @@ -18,38 +36,80 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Read the plugin version + id: version + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(sed -n 's/^ \* Version: *\([^ ]*\)$/\1/p' bibliography-builder.php)" + if [ -z "$version" ]; then + echo "Could not read Version from bibliography-builder.php" >&2 + exit 1 + fi + stable="$(sed -n 's/^Stable tag: *\([^ ]*\)$/\1/p' readme.txt)" + package="$(node -p "require('./package.json').version")" + block="$(node -p "require('./block.json').version")" + for other in "$stable" "$package" "$block"; do + if [ "$other" != "$version" ]; then + echo "Versions disagree: plugin ${version}, readme.txt ${stable}, package.json ${package}, block.json ${block}" >&2 + exit 1 + fi + done + if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "v${version}" != "$GITHUB_REF_NAME" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match plugin version ${version}" >&2 + exit 1 + fi + echo "tag=v${version}" >> "$GITHUB_OUTPUT" + if gh release view "v${version}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release v${version} already exists; nothing to do." + echo "exists=true" >> "$GITHUB_OUTPUT" + fi + - name: Set up Node.js + if: steps.version.outputs.exists != 'true' uses: actions/setup-node@v5 with: node-version: 22 cache: npm - name: Install dependencies + if: steps.version.outputs.exists != 'true' run: npm ci --legacy-peer-deps - name: Build + if: steps.version.outputs.exists != 'true' run: npm run build - name: Package release zip + if: steps.version.outputs.exists != 'true' run: npm run package:release - # The release body is this version's CHANGELOG section, followed by - # GitHub's generated pull-request list (the action prepends the body - # to the generated notes). Fails if CHANGELOG.md has no entry for - # the tag, before anything is published. + # Fails if CHANGELOG.md has no entry for the version, before + # anything is published. - name: Extract release notes from CHANGELOG - run: node scripts/extract-changelog-section.js "$GITHUB_REF_NAME" > output/release/release-notes.md + if: steps.version.outputs.exists != 'true' + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: node scripts/extract-changelog-section.js "$RELEASE_TAG" > output/release/release-notes.md + # Creates the tag at this commit when it does not exist yet. - name: Create GitHub Release + if: steps.version.outputs.exists != 'true' uses: softprops/action-gh-release@v3 with: + tag_name: ${{ steps.version.outputs.tag }} + target_commitish: ${{ github.sha }} files: output/release/borges-bibliography-builder.zip body_path: output/release/release-notes.md generate_release_notes: true fail_on_unmatched_files: true overwrite_files: true + # A release published with the workflow token does not trigger + # wp-deploy.yml's release event, so dispatch it on the tag. - name: Dispatch WordPress.org deploy + if: steps.version.outputs.exists != 'true' env: GH_TOKEN: ${{ github.token }} - run: gh workflow run wp-deploy.yml --ref "$GITHUB_REF_NAME" + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: gh workflow run wp-deploy.yml --ref "$RELEASE_TAG" diff --git a/CLAUDE.md b/CLAUDE.md index 8c48e38c..67d7ea4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,13 +108,13 @@ The Playground blueprint at `playground/blueprint.json` configures the GitHub/re ### Release -`npm run package:release` builds the production zip (strips dev dependencies). Pushing a `v*` tag triggers `release.yml`, which builds the ZIP, publishes the GitHub Release (its body is the tag's `CHANGELOG.md` section, extracted by `scripts/extract-changelog-section.js`, followed by GitHub's generated PR list; the job fails if that section is missing), and dispatches `wp-deploy.yml`. `wp-deploy.yml` also runs on published GitHub releases and can be run manually. +`npm run package:release` builds the production zip (strips dev dependencies). `release.yml` runs when a push to `main` changes `bibliography-builder.php` (so merging a version bump releases it), when a `v*` tag is pushed, or by hand. It reads the version from the plugin header, fails unless `readme.txt`, `package.json` and `block.json` agree, and does nothing if that version's release exists. Otherwise it builds the ZIP, creates the tag, publishes the GitHub Release (its body is the tag's `CHANGELOG.md` section, extracted by `scripts/extract-changelog-section.js`, followed by GitHub's generated PR list; the job fails if that section is missing), and dispatches `wp-deploy.yml`. `wp-deploy.yml` also runs on published GitHub releases and can be run manually. ## CI overview - **ci.yml** — lint, Jest, PHPUnit, Psalm, Playwright Playground E2E, coverage upload, package artifact - **runtime-matrix.yml** — smoke tests across PHP 7.4–8.4, WP 6.4+, Apache/Nginx, MySQL via Docker. They run against the packaged release ZIP contents (`WP_BIBLIO_PLUGIN_DIR`), not the checkout. Beyond activation and the read routes, `scripts/runtime-matrix/smoke.sh` checks formatter parity with `tests/fixtures/csl-styles/` goldens on each PHP version, times a 200-entry `/format` request over HTTP with `scripts/time-format-route.sh` (also usable by hand against any site), and exercises the write routes over HTTP with an application password. -- **release.yml** — tag-triggered GitHub Release and ZIP publication +- **release.yml** — GitHub Release and ZIP publication on a version bump merged to `main`, a `v*` tag, or by hand - **wp-deploy.yml** — deploys to WordPress.org SVN on published GitHub release, release dispatch, or manual run - **codeql.yml** — security analysis - **demo-links.yml** — scheduled/manual check that Playground demo blueprint install URLs stay reachable and never regress to the browser-broken `git:directory` resource diff --git a/docs/release-readiness-checklist.md b/docs/release-readiness-checklist.md index 4c64238b..a4651667 100644 --- a/docs/release-readiness-checklist.md +++ b/docs/release-readiness-checklist.md @@ -114,10 +114,14 @@ miss — nothing in CI checks any of them. ## Publish and verify -Merging the release PR does not release anything. `wp-deploy.yml` runs on -`release: published` (or manual dispatch), so wordpress.org keeps serving the -previous tag — and keeps showing its `Tested up to` — until a release exists. - -- [ ] Tag created and GitHub release published +Merging the release PR releases it. When a push to `main` changes +`bibliography-builder.php`, `release.yml` reads the version, checks that the +plugin header, `readme.txt`, `package.json` and `block.json` agree, and, if no +GitHub release exists for it yet, creates the tag and the release and +dispatches `wp-deploy.yml`. So run this checklist before merging the version +bump, not after. Pushing a matching `v*` tag, or running `release.yml` by +hand, does the same; an existing release is never changed. + +- [ ] `release.yml` run completed: tag created and GitHub release published - [ ] `wp-deploy` run completed - [ ] wordpress.org listing shows the new version and `Tested up to` diff --git a/src/release-workflows.test.js b/src/release-workflows.test.js index 0a1f2052..9a51e62f 100644 --- a/src/release-workflows.test.js +++ b/src/release-workflows.test.js @@ -8,7 +8,7 @@ const { const rootDir = path.resolve(__dirname, '..'); describe('release workflows', () => { - test('tag release workflow dispatches the WordPress.org deploy workflow', () => { + test('release workflow dispatches the WordPress.org deploy workflow on the release tag', () => { const releaseWorkflow = fs.readFileSync( path.join(rootDir, '.github/workflows/release.yml'), 'utf8' @@ -16,7 +16,26 @@ describe('release workflows', () => { expect(releaseWorkflow).toMatch(/actions:\s+write/u); expect(releaseWorkflow).toContain( - 'gh workflow run wp-deploy.yml --ref "$GITHUB_REF_NAME"' + 'gh workflow run wp-deploy.yml --ref "$RELEASE_TAG"' + ); + }); + + test('release workflow runs on a tag, a version bump on main, or by hand', () => { + const releaseWorkflow = fs.readFileSync( + path.join(rootDir, '.github/workflows/release.yml'), + 'utf8' + ); + + expect(releaseWorkflow).toMatch(/tags:\s+- 'v\*'/u); + expect(releaseWorkflow).toMatch( + /branches:\s+- main\s+paths:\s+- 'bibliography-builder\.php'/u + ); + expect(releaseWorkflow).toContain('workflow_dispatch:'); + // An existing release is left alone, so re-runs and unrelated edits + // to the main file are no-ops. + expect(releaseWorkflow).toContain('gh release view "v${version}"'); + expect(releaseWorkflow).toContain( + 'tag_name: ${{ steps.version.outputs.tag }}' ); }); @@ -27,7 +46,7 @@ describe('release workflows', () => { ); expect(releaseWorkflow).toContain( - 'node scripts/extract-changelog-section.js "$GITHUB_REF_NAME" > output/release/release-notes.md' + 'node scripts/extract-changelog-section.js "$RELEASE_TAG" > output/release/release-notes.md' ); expect(releaseWorkflow).toContain( 'body_path: output/release/release-notes.md'