diff --git a/.github/workflows/release-plugin.yml b/.github/workflows/release-plugin.yml index ed8e927..184fcb0 100644 --- a/.github/workflows/release-plugin.yml +++ b/.github/workflows/release-plugin.yml @@ -1,25 +1,79 @@ name: Deploy to WordPress.org + +# Releases whenever master carries a version that has not been tagged yet, so +# merging a PR that bumps the version ships it. Pushes without a version bump +# find the tag already exists and skip everything after the check. +# +# Tags are created by this workflow, not pushed by hand. Pushing a tag no longer +# triggers a release, and the tag this workflow pushes with GITHUB_TOKEN does +# not start any other workflow run. To retry a failed release, use "Re-run +# failed jobs". on: push: - tags: - - "*" + branches: + - master + workflow_dispatch: + +# One release run at a time, so two quick merges cannot deploy the same version +# twice. A run that has started is never cancelled part way through a deploy. +concurrency: + group: release-plugin + cancel-in-progress: false jobs: + check: + name: Check version + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + outputs: + version: ${{ steps.version.outputs.version }} + release: ${{ steps.tag.outputs.release }} + steps: + - uses: actions/checkout@v4 + + - name: Check version numbers and changelog + id: version + run: echo "version=$(./scripts/check-version.sh)" >> "$GITHUB_OUTPUT" + + - name: Check whether this version has been released + id: tag + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" > /dev/null; then + echo "Tag $VERSION already exists, nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "Tag $VERSION does not exist yet, releasing." + echo "release=true" >> "$GITHUB_OUTPUT" + fi + php-tests: + needs: check + if: needs.check.outputs.release == 'true' uses: ./.github/workflows/php-tests.yml jest-tests: + needs: check + if: needs.check.outputs.release == 'true' uses: ./.github/workflows/jest-tests.yml e2e: + needs: check + if: needs.check.outputs.release == 'true' uses: ./.github/workflows/e2e.yml - tag: + release: name: Build plugin and deploy to WordPress.org runs-on: ubuntu-latest - needs: [php-tests, jest-tests, e2e] + needs: [check, php-tests, jest-tests, e2e] + if: needs.check.outputs.release == 'true' + permissions: + contents: write + env: + VERSION: ${{ needs.check.outputs.version }} steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -40,6 +94,8 @@ jobs: run: | rm common-knowledge-join-flow.zip + # Skips the SVN commit if this version is already on WordPress.org, so + # re-running after a later step fails is safe. - name: WordPress Plugin Deploy uses: 10up/action-wordpress-plugin-deploy@stable env: @@ -48,7 +104,19 @@ jobs: SLUG: common-knowledge-join-flow BUILD_DIR: dist/common-knowledge-join-flow + # Tagged only after the deploy succeeds, because the check job treats an + # existing tag as already released. + - name: Tag the release + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" > /dev/null; then + echo "Tag $VERSION already exists." + else + git tag "$VERSION" "$GITHUB_SHA" + git push origin "refs/tags/$VERSION" + fi + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ env.VERSION }} generate_release_notes: true diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml new file mode 100644 index 0000000..1400e70 --- /dev/null +++ b/.github/workflows/script-tests.yml @@ -0,0 +1,18 @@ +name: Script Tests + +on: + push: + branches: + - master + pull_request: + workflow_call: + +jobs: + script-tests: + name: Script tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Test version check + run: ./scripts/check-version.test.sh diff --git a/readme.md b/readme.md index 49768c3..b9c7e32 100644 --- a/readme.md +++ b/readme.md @@ -351,23 +351,27 @@ This produces a deployable WordPress plugin in `packages/join-block`. Releases are automated via GitHub Actions (`.github/workflows/release-plugin.yml`). -1. Bump the version: +1. In the PR that should ship, bump the version and add changelog entries: ```bash ./scripts/bump-version.sh # auto-increments patch ./scripts/bump-version.sh 1.4.0 # or specify a version ``` -2. Commit and tag: +2. Commit the bump to the PR: ```bash git add -A git commit -m "Bump version to 1.4.0" -git tag 1.4.0 -git push && git push --tags ``` -The GitHub Action will build, package, and deploy to the WordPress.org plugin repository automatically. Monitor progress in the Actions tab. +3. Merge the PR. + +On every push to `master`, the workflow reads the version from `packages/join-block/join.php`. If no tag exists for it yet, it runs the PHP, Jest and E2E tests, builds the plugin, deploys it to the WordPress.org plugin repository, then creates the tag and the GitHub Release. If the tag already exists, nothing is released. Monitor progress in the Actions tab. + +The workflow fails before releasing if the version in `join.php`, the `Stable tag:` in `readme.txt` and the `release:` in `packages/join-flow/src/index.tsx` disagree, or if `readme.txt` has no changelog entry for the version. Run `./scripts/check-version.sh` to check this locally. + +Don't push tags by hand: a tag push no longer triggers a release, and a hand-made tag would make the workflow skip that version. If a release fails, use "Re-run failed jobs" on the run. Re-running is safe: the deploy skips a version already on WordPress.org, and the tag is only created after the deploy succeeds. ### Manual deployment diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 02dc136..1c0b49e 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -122,6 +122,5 @@ echo "" echo "Next steps:" echo " 1. Review changes: git diff" echo " 2. Commit: git add -A && git commit -m 'Bump version to $NEW_VERSION'" -echo " 3. Tag: git tag $NEW_VERSION" -echo " 4. Push: git push && git push --tags" +echo " 3. Push and merge the PR. Merging to master releases $NEW_VERSION, so don't tag it by hand." diff --git a/scripts/check-version.sh b/scripts/check-version.sh new file mode 100755 index 0000000..748bfd2 --- /dev/null +++ b/scripts/check-version.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# Checks that the plugin version is the same in every place it is recorded, +# and that readme.txt has a changelog entry for it. Prints the version. +# +# Usage: ./scripts/check-version.sh [project root] + +set -e + +print_error() { + echo "ERROR: $1" >&2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="${1:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +README_TXT="$PROJECT_ROOT/packages/join-block/readme.txt" +JOIN_PHP="$PROJECT_ROOT/packages/join-block/join.php" +INDEX_TSX="$PROJECT_ROOT/packages/join-flow/src/index.tsx" + +for FILE in "$README_TXT" "$JOIN_PHP" "$INDEX_TSX"; do + if [ ! -f "$FILE" ]; then + print_error "$FILE not found" + exit 1 + fi +done + +PHP_VERSION=$(sed -n 's/^ \* Version: *\([^ ]*\) *$/\1/p' "$JOIN_PHP" | head -n 1) +README_VERSION=$(sed -n 's/^Stable tag: *\([^ ]*\) *$/\1/p' "$README_TXT" | head -n 1) +TSX_VERSION=$(sed -n 's/^ *release: "\([^"]*\)".*$/\1/p' "$INDEX_TSX" | head -n 1) + +if ! [[ "$PHP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + print_error "Could not read an X.Y.Z version from the Version header in packages/join-block/join.php (found '$PHP_VERSION')" + exit 1 +fi + +if [ "$README_VERSION" != "$PHP_VERSION" ] || [ "$TSX_VERSION" != "$PHP_VERSION" ]; then + print_error "Version numbers disagree:" + echo " packages/join-block/join.php Version: $PHP_VERSION" >&2 + echo " packages/join-block/readme.txt Stable tag: $README_VERSION" >&2 + echo " packages/join-flow/src/index.tsx release: $TSX_VERSION" >&2 + echo "Run ./scripts/bump-version.sh to update all three together." >&2 + exit 1 +fi + +CHANGELOG_ENTRIES=$(awk -v heading="= $PHP_VERSION =" ' + /^== Changelog ==/ { in_changelog = 1; next } + in_changelog && /^== / { exit } + in_changelog && $0 == heading { in_entry = 1; next } + in_entry && /^= / { exit } + in_entry && /^\* / { count++ } + END { print count + 0 } +' "$README_TXT") + +if [ "$CHANGELOG_ENTRIES" -eq 0 ]; then + print_error "packages/join-block/readme.txt has no changelog entry for $PHP_VERSION" + echo "Add a '= $PHP_VERSION =' heading with at least one '* ' line under '== Changelog =='." >&2 + exit 1 +fi + +echo "$PHP_VERSION" diff --git a/scripts/check-version.test.sh b/scripts/check-version.test.sh new file mode 100755 index 0000000..af56721 --- /dev/null +++ b/scripts/check-version.test.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +# Tests for check-version.sh. Run with: ./scripts/check-version.test.sh + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECK="$SCRIPT_DIR/check-version.sh" +FIXTURES=$(mktemp -d) +trap 'rm -rf "$FIXTURES"' EXIT +FAILURES=0 + +# make_fixture [changelog line] +make_fixture() { + local root + root=$(mktemp -d "$FIXTURES/project.XXXXXX") + mkdir -p "$root/packages/join-block" "$root/packages/join-flow/src" + cat > "$root/packages/join-block/join.php" < "$root/packages/join-block/readme.txt" < "$root/packages/join-flow/src/index.tsx" <&1) && [ "$output" = "$expected" ]; then + echo "ok - $name" + else + echo "not ok - $name: expected success printing '$expected', got: $output" + FAILURES=$((FAILURES + 1)) + fi +} + +expect_fail() { + local name="$1" root="$2" message="$3" output + if output=$("$CHECK" "$root" 2>&1); then + echo "not ok - $name: expected failure, got: $output" + FAILURES=$((FAILURES + 1)) + elif [[ "$output" != *"$message"* ]]; then + echo "not ok - $name: expected message containing '$message', got: $output" + FAILURES=$((FAILURES + 1)) + else + echo "ok - $name" + fi +} + +expect_pass "matching versions with a changelog entry" \ + "$(make_fixture 1.2.3 1.2.3 1.2.3 1.2.3)" "1.2.3" + +expect_fail "readme.txt stable tag differs" \ + "$(make_fixture 1.2.3 1.2.2 1.2.3 1.2.3)" "Version numbers disagree" + +expect_fail "index.tsx release differs" \ + "$(make_fixture 1.2.3 1.2.3 1.2.2 1.2.3)" "Version numbers disagree" + +expect_fail "no changelog entry for the version" \ + "$(make_fixture 1.2.3 1.2.3 1.2.3 1.2.2)" "no changelog entry for 1.2.3" + +expect_fail "changelog heading with no entries" \ + "$(make_fixture 1.2.3 1.2.3 1.2.3 1.2.3 "")" "no changelog entry for 1.2.3" + +expect_fail "version header is not X.Y.Z" \ + "$(make_fixture 1.2 1.2 1.2 1.2)" "Could not read an X.Y.Z version" + +expect_pass "real project files" "$SCRIPT_DIR/.." \ + "$(sed -n 's/^Stable tag: *//p' "$SCRIPT_DIR/../packages/join-block/readme.txt")" + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES test(s) failed" + exit 1 +fi +echo "All tests passed"