From 44b93f3443b4a12de3b3b0e2d586d078397bf4b7 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 31 Aug 2026 22:42:37 -0400 Subject: [PATCH 1/7] ci: publish only from approved release PRs --- .github/workflows/deploy-readme-assets.yml | 134 ++- .github/workflows/deploy-to-wordpress.yml | 215 ---- .github/workflows/publication-gate.yml | 140 +++ .github/workflows/release.yml | 988 ++++++++++-------- .release-please-config.json | 15 - .release-please-manifest.json | 3 - bin/README.md | 68 +- bin/prepare-release.js | 154 ++- bin/validate-release-version.js | 144 +++ docs/github-actions.md | 543 +--------- docs/release-please-integration.md | 131 --- readme.md | 27 +- .../unit/bin/validate-release-version.test.js | 77 ++ 13 files changed, 1186 insertions(+), 1453 deletions(-) delete mode 100644 .github/workflows/deploy-to-wordpress.yml create mode 100644 .github/workflows/publication-gate.yml delete mode 100644 .release-please-config.json delete mode 100644 .release-please-manifest.json create mode 100644 bin/validate-release-version.js delete mode 100644 docs/release-please-integration.md create mode 100644 tests/unit/bin/validate-release-version.test.js diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 4868174d8..9abe669bf 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -1,28 +1,134 @@ -name: Sync readme/assets to WordPress.org +name: Sync approved readme/assets PR to WordPress.org on: - push: - branches: - - master - paths: - - readme.txt + pull_request_target: + branches: [master] + types: [closed] workflow_dispatch: + inputs: + pull_request_number: + description: Merged readme/assets PR number to retry. + required: true + type: number + +permissions: + contents: read + pull-requests: read + +concurrency: + group: wordpress-org-readme-assets-${{ github.event.pull_request.number || inputs.pull_request_number }} + cancel-in-progress: false jobs: + authorize: + name: Authorize approved readme/assets PR + runs-on: ubuntu-latest + outputs: + should_sync: ${{ steps.authorize.outputs.should_sync }} + merge_sha: ${{ steps.authorize.outputs.merge_sha }} + pull_request_number: ${{ steps.authorize.outputs.pull_request_number }} + + steps: + - name: Verify merged PR, approval, and exact file scope + id: authorize + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const number = context.eventName === 'workflow_dispatch' + ? Number('${{ inputs.pull_request_number }}') + : context.payload.pull_request.number; + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + + core.setOutput('should_sync', 'false'); + core.setOutput('pull_request_number', String(number)); + + if (!pull.merged || pull.base.ref !== 'master') { + core.info('PR is not merged into master; nothing will be synced.'); + return; + } + if (pull.head.repo?.full_name !== `${owner}/${repo}`) { + core.setFailed('Publication PRs must come from this repository.'); + return; + } + + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: number, + per_page: 100, + }); + const latestByReviewer = new Map(); + for (const review of reviews) { + if ( + review.user?.login && + ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state) + ) { + latestByReviewer.set(review.user.login, review.state); + } + } + const approvedReviewers = [...latestByReviewer.entries()] + .filter(([, state]) => state === 'APPROVED') + .map(([login]) => login); + let authorizedApproval = false; + for (const username of approvedReviewers) { + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + if (['admin', 'maintain', 'write'].includes(permission.permission)) { + authorizedApproval = true; + break; + } + } + if (!authorizedApproval && pull.merged_by?.login) { + const { data: mergerPermission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: pull.merged_by.login, + }); + authorizedApproval = ['admin', 'maintain', 'write'].includes( + mergerPermission.permission + ); + } + if (!authorizedApproval) { + core.setFailed('The PR was not approved or merged by an authorized maintainer.'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: number, + per_page: 100, + }); + const allowed = files.length > 0 && files.every(({ filename }) => + filename === 'readme.txt' || filename.startsWith('.wordpress-org/') + ); + if (!allowed) { + core.info('PR is not readme/assets-only; the narrow SVN sync will not run.'); + return; + } + + core.setOutput('merge_sha', pull.merge_commit_sha); + core.setOutput('should_sync', 'true'); + update: - name: Update WordPress.org readme & assets + name: Update WordPress.org readme and assets + needs: authorize + if: needs.authorize.outputs.should_sync == 'true' runs-on: ubuntu-latest - # Only run against the protected master branch. A manual dispatch on any - # other ref must not be able to sync unreviewed content — or a malicious - # branch copy of this workflow — using the WordPress.org SVN credentials. - if: github.ref == 'refs/heads/master' steps: - - uses: actions/checkout@v6 + - name: Checkout approved merge + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: master + ref: ${{ needs.authorize.outputs.merge_sha }} - - uses: 10up/action-wordpress-plugin-asset-update@stable + - name: Sync readme and assets + uses: 10up/action-wordpress-plugin-asset-update@2480306f6f693672726d08b5917ea114cb2825f7 # stable env: SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SVN_USERNAME: ${{ secrets.SVN_USERNAME }} diff --git a/.github/workflows/deploy-to-wordpress.yml b/.github/workflows/deploy-to-wordpress.yml deleted file mode 100644 index 3732c5412..000000000 --- a/.github/workflows/deploy-to-wordpress.yml +++ /dev/null @@ -1,215 +0,0 @@ -name: Deploy to WordPress.org - -on: - push: - branches: - - master - paths-ignore: - - readme.txt - workflow_dispatch: - inputs: - dry_run: - description: 'Dry run (no actual deploy)' - type: boolean - default: false - -env: - PLUGIN_SLUG: popup-maker - PLUGIN_NAME: Popup Maker - BUILD_DIR: ${{ github.workspace }}/.wordpress-org-build/popup-maker - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - deploy: - name: Deploy to WordPress.org SVN - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - dry_run: ${{ steps.flags.outputs.dry_run }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Determine flags - id: flags - env: - DRY_RUN_INPUT: ${{ inputs.dry_run }} - GITHUB_REF: ${{ github.ref }} - run: | - DRY_RUN="${DRY_RUN_INPUT:-false}" - - # Only the protected master branch may perform a real deploy. - # Any other ref (e.g. a manually dispatched branch) is forced to dry-run. - if [ "${GITHUB_REF}" != "refs/heads/master" ]; then - echo "⚠️ Ref is ${GITHUB_REF}, not refs/heads/master — forcing dry-run." - DRY_RUN="true" - fi - - echo "dry_run=${DRY_RUN}" >> $GITHUB_OUTPUT - - - name: Extract version from plugin header - id: version - run: | - VERSION=$(grep "Version:" popup-maker.php | head -1 | sed 's/.*Version:\s*\([0-9.]*\).*/\1/' | xargs) - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "📦 Version: ${VERSION}" - - # Primary path: deploy the published release asset that was built and - # tested during the release. Fall back to building from source only when - # the asset is missing. - - name: Try downloading release zip - id: download - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - REPO: ${{ github.repository }} - run: | - ZIP_NAME="${{ env.PLUGIN_SLUG }}_${VERSION}.zip" - - gh release download "${VERSION}" \ - --pattern "${ZIP_NAME}" \ - --dir . \ - --repo "${REPO}" 2>/dev/null || true - - if [ -f "${ZIP_NAME}" ]; then - echo "✅ Downloaded ${ZIP_NAME}" - echo "found=true" >> $GITHUB_OUTPUT - echo "zip_name=${ZIP_NAME}" >> $GITHUB_OUTPUT - else - echo "⚠️ Release zip not found, will build from source" - echo "found=false" >> $GITHUB_OUTPUT - fi - - - name: Verify downloaded release zip - if: steps.download.outputs.found == 'true' - env: - ZIP_NAME: ${{ steps.download.outputs.zip_name }} - run: node bin/verify-release-artifact.js "${ZIP_NAME}" - - # --- Path A: extract the release zip into BUILD_DIR --- - - name: Extract release zip - if: steps.download.outputs.found == 'true' - env: - ZIP_NAME: ${{ steps.download.outputs.zip_name }} - run: | - mkdir -p "${{ env.BUILD_DIR }}" - unzip -o "${ZIP_NAME}" -d "$(dirname "${{ env.BUILD_DIR }}")" - echo "✅ Extracted to ${{ env.BUILD_DIR }}" - ls -la "${{ env.BUILD_DIR }}" - - # --- Path B: build from source into BUILD_DIR (fallback) --- - - name: Setup PHP - if: steps.download.outputs.found != 'true' - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - tools: composer:v2 - coverage: none - - - name: Setup pnpm - if: steps.download.outputs.found != 'true' - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - if: steps.download.outputs.found != 'true' - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: 'pnpm' - - - name: Install dependencies - if: steps.download.outputs.found != 'true' - run: | - # Strauss 0.22.4 cannot parse the runner-injected OAuth token. - composer config --global --unset github-oauth.github.com || true - composer install --no-dev --optimize-autoloader --no-interaction --no-progress - pnpm install --frozen-lockfile - - - name: Build into BUILD_DIR - if: steps.download.outputs.found != 'true' - run: | - pnpm run build:production - - mkdir -p "${{ env.BUILD_DIR }}" - - if [ -f "bin/build-release.js" ]; then - # --output-dir only controls where the zip lands; the SVN deploy - # needs the unpacked plugin tree. Keep the assembled build/ tree - # (renamed to ./popup-maker) and copy its contents into BUILD_DIR. - node bin/build-release.js --keep-build - cp -r popup-maker/. "${{ env.BUILD_DIR }}/" - else - # Manual copy matching what build-release.js produces. - cp -r classes "${{ env.BUILD_DIR }}/" - cp -r includes "${{ env.BUILD_DIR }}/" - cp -r languages "${{ env.BUILD_DIR }}/" 2>/dev/null || true - cp -r vendor-prefixed "${{ env.BUILD_DIR }}/" 2>/dev/null || true - [ -d "dist" ] && cp -r dist "${{ env.BUILD_DIR }}/" - [ -d "assets" ] && cp -r assets "${{ env.BUILD_DIR }}/" - cp popup-maker.php "${{ env.BUILD_DIR }}/" - cp readme.txt "${{ env.BUILD_DIR }}/" - cp LICENSE "${{ env.BUILD_DIR }}/" 2>/dev/null || true - fi - - echo "✅ Built into ${{ env.BUILD_DIR }}" - ls -la "${{ env.BUILD_DIR }}" - - # Keep mixed code/readme pushes from deploying the release ZIP's stale readme. - - name: Use current readme - run: cp readme.txt "${{ env.BUILD_DIR }}/readme.txt" - - - name: Run WordPress Plugin Check - uses: wordpress/plugin-check-action@v1 - with: - build-dir: ${{ env.BUILD_DIR }} - slug: popup-maker - exclude-checks: plugin_review_phpcs - exclude-directories: vendor-prefixed - exclude-files: | - classes/Extension/Updater.php - includes/legacy/deprecated-classes.php - ignore-codes: | - WordPress.WP.I18n.MissingArgDomain - missing_direct_file_access_protection - ignore-warnings: true - - # --- Deploy (same for both paths) --- - - name: Deploy to WordPress.org - uses: 10up/action-wordpress-plugin-deploy@stable - with: - dry-run: ${{ steps.flags.outputs.dry_run }} - env: - SVN_USERNAME: ${{ secrets.SVN_USERNAME }} - SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} - SLUG: ${{ env.PLUGIN_SLUG }} - VERSION: ${{ steps.version.outputs.version }} - BUILD_DIR: ${{ env.BUILD_DIR }} - ASSETS_DIR: .wordpress-org - - notify-failure: - name: Slack Failure Notification - runs-on: ubuntu-latest - needs: deploy - if: needs.deploy.result == 'failure' && needs.deploy.outputs.dry_run != 'true' - - steps: - - name: Send failure notification - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - PLUGIN_NAME: ${{ env.PLUGIN_NAME }} - VERSION: ${{ needs.deploy.outputs.version }} - RUN_ID: ${{ github.run_id }} - REPO: ${{ github.repository }} - run: | - if [ -z "$SLACK_WEBHOOK_URL" ]; then - exit 0 - fi - - MESSAGE="❌ *${PLUGIN_NAME} ${VERSION}* — WordPress.org deploy FAILED\n\n" - MESSAGE="${MESSAGE}🔍 " - - curl -X POST \ - --data-urlencode "payload={\"text\": \"${MESSAGE}\", \"username\": \"Release Bot\", \"icon_emoji\": \":package:\"}" \ - "$SLACK_WEBHOOK_URL" \ - --fail --silent --show-error diff --git a/.github/workflows/publication-gate.yml b/.github/workflows/publication-gate.yml new file mode 100644 index 000000000..7fe66755d --- /dev/null +++ b/.github/workflows/publication-gate.yml @@ -0,0 +1,140 @@ +name: Publication Gate + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: publication-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + publication-gate: + name: Publication Gate + runs-on: ubuntu-latest + + steps: + - name: Checkout proposed merge + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Classify publication + id: publication + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + run: | + CHANGED_FILES=$(git diff --name-only "${BASE_SHA}" "HEAD") + echo "${CHANGED_FILES}" + + if [[ "${HEAD_BRANCH}" =~ ^release/([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + if [ "${HEAD_REPOSITORY}" != "${REPOSITORY}" ]; then + echo "Release branches must come from this repository." + exit 1 + fi + + echo "type=release" >> "${GITHUB_OUTPUT}" + echo "version=${BASH_REMATCH[1]}" >> "${GITHUB_OUTPUT}" + elif [ -n "${CHANGED_FILES}" ] && ! echo "${CHANGED_FILES}" | grep -Ev '^(readme\.txt|\.wordpress-org/)' >/dev/null; then + echo "type=readme-assets" >> "${GITHUB_OUTPUT}" + else + echo "type=none" >> "${GITHUB_OUTPUT}" + fi + + - name: Set up PHP + if: steps.publication.outputs.type == 'release' + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.0' + tools: composer:v2 + coverage: none + + - name: Set up pnpm + if: steps.publication.outputs.type == 'release' + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: Set up Node.js + if: steps.publication.outputs.type == 'release' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + cache: pnpm + + - name: Validate release version + if: steps.publication.outputs.type == 'release' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + VERSION: ${{ steps.publication.outputs.version }} + run: | + PREVIOUS_VERSION=$(git show "${BASE_SHA}:package.json" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") + node bin/validate-release-version.js \ + --version "${VERSION}" \ + --previous-version "${PREVIOUS_VERSION}" + + - name: Install release dependencies + if: steps.publication.outputs.type == 'release' + run: | + composer config --global --unset github-oauth.github.com || true + composer install --no-dev --optimize-autoloader --no-interaction --no-progress + pnpm install --frozen-lockfile + + - name: Build and verify release candidate + if: steps.publication.outputs.type == 'release' + env: + VERSION: ${{ steps.publication.outputs.version }} + run: | + pnpm run build:production + node bin/build-release.js --zip-name "popup-maker_${VERSION}.zip" --keep-build + node bin/verify-release-artifact.js "popup-maker_${VERSION}.zip" + + - name: Run WordPress Plugin Check + if: steps.publication.outputs.type == 'release' + uses: wordpress/plugin-check-action@10857da14b6c2246d15402b3e69f777edcf8c12e # v1 + with: + build-dir: ./popup-maker + slug: popup-maker + exclude-checks: plugin_review_phpcs + exclude-directories: vendor-prefixed + exclude-files: | + classes/Extension/Updater.php + includes/legacy/deprecated-classes.php + ignore-codes: | + WordPress.WP.I18n.MissingArgDomain + missing_direct_file_access_protection + ignore-warnings: true + + - name: Upload release candidate + if: steps.publication.outputs.type == 'release' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: popup-maker-${{ steps.publication.outputs.version }}-pr-${{ github.event.pull_request.number }} + path: popup-maker_${{ steps.publication.outputs.version }}.zip + retention-days: 30 + + - name: Explain publication path + env: + PUBLICATION_TYPE: ${{ steps.publication.outputs.type }} + VERSION: ${{ steps.publication.outputs.version }} + run: | + case "${PUBLICATION_TYPE}" in + release) + echo "## Full release ${VERSION}" >> "${GITHUB_STEP_SUMMARY}" + echo "Merging this approved release PR will run the full release pipeline." >> "${GITHUB_STEP_SUMMARY}" + ;; + readme-assets) + echo "## WordPress.org readme/assets update" >> "${GITHUB_STEP_SUMMARY}" + echo "Merging this approved PR will sync only readme.txt and .wordpress-org assets." >> "${GITHUB_STEP_SUMMARY}" + ;; + *) + echo "## No publication" >> "${GITHUB_STEP_SUMMARY}" + echo "Merging this PR will not update WordPress.org or create a release." >> "${GITHUB_STEP_SUMMARY}" + ;; + esac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7fae7645..010057351 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,451 +1,553 @@ -name: Release +name: Publish approved release PR on: - push: - tags: - - '[0-9]+\.[0-9]+\.[0-9]+' - - '[0-9]+\.[0-9]+\.[0-9]+-*' + pull_request_target: + branches: [master] + types: [closed] + workflow_dispatch: + inputs: + pull_request_number: + description: Merged release PR number to retry. + required: true + type: number + +permissions: + contents: read + pull-requests: read + +concurrency: + group: popup-maker-release-${{ github.event.pull_request.number || inputs.pull_request_number }} + cancel-in-progress: false env: - PLUGIN_SLUG: popup-maker - PLUGIN_NAME: Popup Maker - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PLUGIN_SLUG: popup-maker + PLUGIN_NAME: Popup Maker + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - # ============================================================================ - # BUILD RELEASE PACKAGE - # ============================================================================ - build: - name: Build Release Package - runs-on: ubuntu-latest - outputs: - version: ${{ steps.info.outputs.version }} - is_test: ${{ steps.info.outputs.is_test }} - is_prerelease: ${{ steps.info.outputs.is_prerelease }} - zip_name: ${{ steps.package.outputs.name }} - changelog_content: ${{ steps.changelog.outputs.content }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Extract release information - id: info - env: - TAG: ${{ github.ref_name }} - run: | - VERSION="${TAG}" - IS_TEST="false" - IS_PRERELEASE="false" - - if [[ "$VERSION" =~ -test$ ]]; then - IS_TEST="true" - VERSION="${VERSION%-test}" - elif [[ "$VERSION" =~ -(alpha|beta|rc) ]]; then - IS_PRERELEASE="true" - fi - - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "is_test=${IS_TEST}" >> $GITHUB_OUTPUT - echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT - - echo "📦 Plugin: ${{ env.PLUGIN_NAME }}" - echo "🏷️ Version: ${VERSION}" - echo "🧪 Test: ${IS_TEST}" - echo "🔖 Pre-release: ${IS_PRERELEASE}" - - - name: Extract changelog - id: changelog + authorize: + name: Authorize approved release PR + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/')) + runs-on: ubuntu-latest + outputs: + merge_sha: ${{ steps.authorize.outputs.merge_sha }} + base_sha: ${{ steps.authorize.outputs.base_sha }} + pull_request_number: ${{ steps.authorize.outputs.pull_request_number }} + version: ${{ steps.authorize.outputs.version }} + + steps: + - name: Verify merged PR, approval, branch, and tag + id: authorize + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const number = context.eventName === 'workflow_dispatch' + ? Number('${{ inputs.pull_request_number }}') + : context.payload.pull_request.number; + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + + if (!pull.merged || pull.base.ref !== 'master') { + core.setFailed('The release PR must be merged into master.'); + return; + } + if (pull.head.repo?.full_name !== `${owner}/${repo}`) { + core.setFailed('Release PRs must come from this repository.'); + return; + } + + const branchMatch = pull.head.ref.match(/^release\/(\d+\.\d+\.\d+)$/); + if (!branchMatch) { + core.setFailed('The merged branch must be named release/X.Y.Z.'); + return; + } + + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: number, + per_page: 100, + }); + const latestByReviewer = new Map(); + for (const review of reviews) { + if ( + review.user?.login && + ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state) + ) { + latestByReviewer.set(review.user.login, review.state); + } + } + const approvedReviewers = [...latestByReviewer.entries()] + .filter(([, state]) => state === 'APPROVED') + .map(([login]) => login); + let authorizedApproval = false; + for (const username of approvedReviewers) { + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + if (['admin', 'maintain', 'write'].includes(permission.permission)) { + authorizedApproval = true; + break; + } + } + if (!authorizedApproval && pull.merged_by?.login) { + const { data: mergerPermission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: pull.merged_by.login, + }); + authorizedApproval = ['admin', 'maintain', 'write'].includes( + mergerPermission.permission + ); + } + if (!authorizedApproval) { + core.setFailed('The release PR was not approved or merged by an authorized maintainer.'); + return; + } + + const version = branchMatch[1]; + let existingTagCommit = ''; + try { + const { data: ref } = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${version}`, + }); + existingTagCommit = ref.object.sha; + if (ref.object.type === 'tag') { + const { data: tag } = await github.rest.git.getTag({ + owner, + repo, + tag_sha: ref.object.sha, + }); + existingTagCommit = tag.object.sha; + } + } catch (error) { + if (error.status !== 404) throw error; + } + if (existingTagCommit && existingTagCommit !== pull.merge_commit_sha) { + core.setFailed(`Tag ${version} already points to another commit.`); + return; + } + + core.setOutput('merge_sha', pull.merge_commit_sha); + core.setOutput('base_sha', pull.base.sha); + core.setOutput('pull_request_number', String(number)); + core.setOutput('version', version); + + build: + name: Build canonical release package + needs: authorize + runs-on: ubuntu-latest + outputs: + zip_name: ${{ steps.package.outputs.name }} + changelog_content: ${{ steps.changelog.outputs.content }} + + steps: + - name: Checkout approved merge + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.authorize.outputs.merge_sha }} + fetch-depth: 0 + + - name: Validate release version + env: + BASE_SHA: ${{ needs.authorize.outputs.base_sha }} + VERSION: ${{ needs.authorize.outputs.version }} + run: | + PREVIOUS_VERSION=$(git show "${BASE_SHA}:package.json" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") + node bin/validate-release-version.js \ + --version "${VERSION}" \ + --previous-version "${PREVIOUS_VERSION}" + + - name: Extract changelog + id: changelog + env: + VERSION: ${{ needs.authorize.outputs.version }} + run: | + CHANGELOG=$(node bin/extract-changelog.js "${VERSION}") + if [ -z "${CHANGELOG}" ]; then + echo "No changelog content found for ${VERSION}." + exit 1 + fi + { + echo 'content<> "${GITHUB_OUTPUT}" + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.0' + tools: composer:v2 + coverage: none + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + cache: pnpm + + - name: Install dependencies + run: | + composer config --global --unset github-oauth.github.com || true + composer install --no-dev --optimize-autoloader --no-interaction --no-progress + pnpm install --frozen-lockfile + + - name: Build production assets + run: pnpm run build:production + + - name: Create and verify release package + id: package + env: + VERSION: ${{ needs.authorize.outputs.version }} + run: | + ZIP_NAME="${PLUGIN_SLUG}_${VERSION}.zip" + node bin/build-release.js --zip-name "${ZIP_NAME}" --keep-build + node bin/verify-release-artifact.js "${ZIP_NAME}" + echo "name=${ZIP_NAME}" >> "${GITHUB_OUTPUT}" + + - name: Run WordPress Plugin Check + uses: wordpress/plugin-check-action@10857da14b6c2246d15402b3e69f777edcf8c12e # v1 + with: + build-dir: ./popup-maker + slug: popup-maker + exclude-checks: plugin_review_phpcs + exclude-directories: vendor-prefixed + exclude-files: | + classes/Extension/Updater.php + includes/legacy/deprecated-classes.php + ignore-codes: | + WordPress.WP.I18n.MissingArgDomain + missing_direct_file_access_protection + ignore-warnings: true + + - name: Upload canonical release package + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: ${{ env.PLUGIN_SLUG }}-${{ needs.authorize.outputs.version }} + path: ${{ steps.package.outputs.name }} + retention-days: 30 + + github-release: + name: Create GitHub tag and release + needs: [authorize, build] + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + release_id: ${{ steps.release.outputs.id }} + release_url: ${{ steps.release.outputs.url }} + + steps: + - name: Download canonical release package + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ env.PLUGIN_SLUG }}-${{ needs.authorize.outputs.version }} + + - name: Create or verify release + id: release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ needs.authorize.outputs.version }} + target_commitish: ${{ needs.authorize.outputs.merge_sha }} + name: ${{ env.PLUGIN_NAME }} ${{ needs.authorize.outputs.version }} + body: ${{ needs.build.outputs.changelog_content }} + files: ${{ needs.build.outputs.zip_name }} + draft: false + prerelease: false + make_latest: true + + wordpress-org: + name: Deploy canonical package to WordPress.org + needs: [authorize, build, github-release] + runs-on: ubuntu-latest env: - VERSION: ${{ steps.info.outputs.version }} - run: | - CHANGELOG="" - - if [ -f "bin/extract-changelog.js" ]; then - CHANGELOG=$(node bin/extract-changelog.js "$VERSION" 2>/dev/null || echo "") - fi - - if [ -z "$CHANGELOG" ] && [ -f "CHANGELOG.md" ]; then - CHANGELOG=$(sed -n "/^## \[*v*${VERSION}[]\) -]*/,/^## /p" CHANGELOG.md | sed '1d;$d' | awk 'NF {p=1} p') - fi - - if [ -z "$CHANGELOG" ]; then - CHANGELOG="Release ${VERSION}" - fi - - { - echo 'content<> $GITHUB_OUTPUT - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - tools: composer:v2 - coverage: none - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v6 + BUILD_DIR: ${{ github.workspace }}/.wordpress-org-build/popup-maker + + steps: + - name: Checkout approved merge + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.authorize.outputs.merge_sha }} + + - name: Download canonical release package + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ env.PLUGIN_SLUG }}-${{ needs.authorize.outputs.version }} + + - name: Verify and extract canonical release package + env: + ZIP_NAME: ${{ needs.build.outputs.zip_name }} + run: | + node bin/verify-release-artifact.js "${ZIP_NAME}" + mkdir -p "$(dirname "${BUILD_DIR}")" + unzip -o "${ZIP_NAME}" -d "$(dirname "${BUILD_DIR}")" + test -f "${BUILD_DIR}/popup-maker.php" + + - name: Run WordPress Plugin Check + uses: wordpress/plugin-check-action@10857da14b6c2246d15402b3e69f777edcf8c12e # v1 + with: + build-dir: ${{ env.BUILD_DIR }} + slug: popup-maker + exclude-checks: plugin_review_phpcs + exclude-directories: vendor-prefixed + exclude-files: | + classes/Extension/Updater.php + includes/legacy/deprecated-classes.php + ignore-codes: | + WordPress.WP.I18n.MissingArgDomain + missing_direct_file_access_protection + ignore-warnings: true + + - name: Deploy to WordPress.org + uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # stable + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + SLUG: ${{ env.PLUGIN_SLUG }} + VERSION: ${{ needs.authorize.outputs.version }} + BUILD_DIR: ${{ env.BUILD_DIR }} + ASSETS_DIR: .wordpress-org + + edd-webhook: + name: Notify EDD Store + needs: [authorize, build, github-release] + runs-on: ubuntu-latest + + steps: + - name: Resolve release asset + id: asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PACKAGE_NAME: ${{ needs.build.outputs.zip_name }} + RELEASE_ID: ${{ needs.github-release.outputs.release_id }} + REPO: ${{ github.repository }} + run: | + ASSET_URL=$(gh api "repos/${REPO}/releases/${RELEASE_ID}" \ + --jq ".assets[] | select(.name == \"${PACKAGE_NAME}\") | .url") + test -n "${ASSET_URL}" + echo "asset_url=${ASSET_URL}" >> "${GITHUB_OUTPUT}" + + - name: Sync release to EDD + uses: code-atlantic/edd-release-sync@33d9c1b6afba496eb16992fd0661c291ad0565f9 # v0.2.0 + with: + edd_id: ${{ vars.EDD_PRODUCT_ID }} + version: ${{ needs.authorize.outputs.version }} + release_url: ${{ needs.github-release.outputs.release_url }} + download_url: https://github.com/${{ github.repository }}/releases/download/${{ needs.authorize.outputs.version }}/${{ needs.build.outputs.zip_name }} + asset_api_url: ${{ steps.asset.outputs.asset_url }} + webhook_url: ${{ vars.EDD_WEBHOOK_URL }} + webhook_token: ${{ secrets.EDD_WEBHOOK_TOKEN }} + plugin_slug: ${{ env.PLUGIN_SLUG }} + is_prerelease: false + test_mode: false + + google-drive-upload: + name: Upload to Google Drive + needs: [authorize, build, github-release] + runs-on: ubuntu-latest + outputs: + download_link: ${{ steps.upload.outputs.download_link }} + + steps: + - name: Download canonical release package + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ env.PLUGIN_SLUG }}-${{ needs.authorize.outputs.version }} + + - name: Upload to Google Drive + id: upload + uses: code-atlantic/sync-release-to-google-drive@b579fe06183c2b49a73a9de4dd25ffc158e9ba87 # v0.3.1 + with: + filename: ${{ needs.build.outputs.zip_name }} + credentials: ${{ secrets.GOOGLE_DRIVE_CREDENTIALS }} + folder_id: ${{ vars.GOOGLE_DRIVE_FOLDER_ID }} + overwrite: 'true' + sharing: anyone + link_discoverable: false + + changelog: + name: Create review-required WordPress changelog draft + needs: [github-release] + uses: code-atlantic/release-changelog-action/.github/workflows/sync-release.yml@3c44b56d2677a434fc85e5610989bb194cf08571 with: - node-version: '24' - cache: 'pnpm' - - - name: Install dependencies - run: | - # Strauss 0.22.4 embeds an older Composer token validator that rejects - # GitHub Actions' generated token format. All release dependencies are - # public, so remove only the runner-injected Composer OAuth entry. - composer config --global --unset github-oauth.github.com || true - composer install --no-dev --optimize-autoloader --no-interaction --no-progress - pnpm install --frozen-lockfile - - - name: Build production assets - run: pnpm run build:production - - - name: Create release package - id: package - env: - VERSION: ${{ steps.info.outputs.version }} - run: | - ZIP_NAME="${{ env.PLUGIN_SLUG }}_${VERSION}.zip" - - if [ -f "bin/build-release.js" ]; then - node bin/build-release.js --zip-name "${ZIP_NAME}" --keep-build - else - pnpm run release -- --keep-build - fi - - if [ ! -f "${ZIP_NAME}" ]; then - echo "❌ Package not found: ${ZIP_NAME}" - exit 1 - fi - - FILE_SIZE=$(ls -lh "${ZIP_NAME}" | awk '{print $5}') - echo "✅ Package created: ${ZIP_NAME} (${FILE_SIZE})" - echo "name=${ZIP_NAME}" >> $GITHUB_OUTPUT - - - name: Run WordPress Plugin Check - uses: wordpress/plugin-check-action@v1 - with: - build-dir: ./popup-maker - slug: popup-maker - exclude-checks: plugin_review_phpcs - exclude-directories: vendor-prefixed - exclude-files: | - classes/Extension/Updater.php - includes/legacy/deprecated-classes.php - ignore-codes: | - WordPress.WP.I18n.MissingArgDomain - missing_direct_file_access_protection - ignore-warnings: true - - - name: Upload artifact - uses: actions/upload-artifact@v6 - with: - name: ${{ env.PLUGIN_SLUG }}-${{ steps.info.outputs.version }} - path: ${{ steps.package.outputs.name }} - retention-days: 30 - - # ============================================================================ - # CREATE GITHUB RELEASE - # ============================================================================ - github-release: - name: Create GitHub Release - runs-on: ubuntu-latest - needs: build - permissions: - contents: write - outputs: - release_url: ${{ steps.set_url.outputs.release_url }} - release_id: ${{ steps.release.outputs.id }} - - steps: - - name: Download artifact - uses: actions/download-artifact@v7 - with: - name: ${{ env.PLUGIN_SLUG }}-${{ needs.build.outputs.version }} - - - name: Create GitHub Release - id: release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - name: ${{ env.PLUGIN_NAME }} ${{ needs.build.outputs.version }} - body: | - ${{ needs.build.outputs.changelog_content }} - files: ${{ needs.build.outputs.zip_name }} - draft: ${{ needs.build.outputs.is_test == 'true' }} - prerelease: ${{ needs.build.outputs.is_prerelease == 'true' }} - - - name: Set release URL - id: set_url - env: - TAG: ${{ github.ref_name }} - REPO: ${{ github.repository }} - run: | - RELEASE_URL="https://github.com/${REPO}/releases/tag/${TAG}" - echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT - - # ============================================================================ - # NOTIFY EDD STORE - # ============================================================================ - edd-webhook: - name: Notify EDD Store - runs-on: ubuntu-latest - needs: [build, github-release] - if: | - always() && - needs.build.result == 'success' && - needs.github-release.result == 'success' - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Get release asset API URL - id: get_asset - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PACKAGE_NAME: ${{ needs.build.outputs.zip_name }} - RELEASE_ID: ${{ needs.github-release.outputs.release_id }} - REPO: ${{ github.repository }} - run: | - # Use release ID (works for draft + published releases). - ASSET_URL=$(gh api "repos/${REPO}/releases/${RELEASE_ID}" \ - --jq ".assets[] | select(.name == \"${PACKAGE_NAME}\") | .url") - - if [ -z "$ASSET_URL" ] || [ "$ASSET_URL" == "null" ]; then - echo "⚠️ Could not find asset URL for ${PACKAGE_NAME}" - echo "asset_url=" >> $GITHUB_OUTPUT - else - echo "asset_url=${ASSET_URL}" >> $GITHUB_OUTPUT - echo "✅ Asset API URL: ${ASSET_URL}" - fi - - - name: Sync release to EDD - uses: code-atlantic/edd-release-sync@v0.2.0 - with: - edd_id: ${{ vars.EDD_PRODUCT_ID }} - version: ${{ needs.build.outputs.version }} - release_url: ${{ needs.github-release.outputs.release_url }} - download_url: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ needs.build.outputs.zip_name }} - asset_api_url: ${{ steps.get_asset.outputs.asset_url }} - webhook_url: ${{ vars.EDD_WEBHOOK_URL }} - webhook_token: ${{ secrets.EDD_WEBHOOK_TOKEN }} - plugin_slug: ${{ env.PLUGIN_SLUG }} - is_prerelease: ${{ needs.build.outputs.is_prerelease }} - test_mode: ${{ needs.build.outputs.is_test }} - - # ============================================================================ - # UPLOAD TO GOOGLE DRIVE - # ============================================================================ - google-drive-upload: - name: Upload to Google Drive - runs-on: ubuntu-latest - needs: [build, github-release] - if: needs.build.result == 'success' && needs.github-release.result == 'success' - outputs: - file_id: ${{ steps.upload.outputs.file_id }} - web_view_link: ${{ steps.upload.outputs.web_view_link }} - download_link: ${{ steps.upload.outputs.download_link }} - updated: ${{ steps.upload.outputs.updated }} - skipped: ${{ steps.upload.outputs.skipped }} - - steps: - - name: Download artifact - uses: actions/download-artifact@v7 - with: - name: ${{ env.PLUGIN_SLUG }}-${{ needs.build.outputs.version }} - - - name: Upload to Google Drive - id: upload - uses: code-atlantic/sync-release-to-google-drive@v0.3.1 - with: - filename: ${{ needs.build.outputs.zip_name }} - credentials: ${{ secrets.GOOGLE_DRIVE_CREDENTIALS }} - folder_id: ${{ vars.GOOGLE_DRIVE_FOLDER_ID }} - overwrite: 'true' - sharing: anyone - link_discoverable: false - - # ============================================================================ - # SLACK NOTIFICATION (runs after EDD + GDrive) - # ============================================================================ - slack-notification-success: - name: Slack Success Notification - runs-on: ubuntu-latest - needs: [build, github-release, edd-webhook, google-drive-upload] - if: | - always() && - needs.build.result == 'success' && - needs.github-release.result == 'success' - - steps: - - name: Send success notification - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - PLUGIN_NAME: ${{ env.PLUGIN_NAME }} - VERSION: ${{ needs.build.outputs.version }} - IS_TEST: ${{ needs.build.outputs.is_test }} - RELEASE_URL: ${{ needs.github-release.outputs.release_url }} - CHANGELOG: ${{ needs.build.outputs.changelog_content }} - DRIVE_DOWNLOAD_URL: ${{ needs.google-drive-upload.outputs.download_link }} - EDD_RESULT: ${{ needs.edd-webhook.result }} - GDRIVE_RESULT: ${{ needs.google-drive-upload.result }} - REPO: ${{ github.repository }} - run: | - if [ -z "$SLACK_WEBHOOK_URL" ]; then - echo "⚠️ No Slack webhook configured" - exit 0 - fi - - EMOJI="🚀" - [ "$IS_TEST" == "true" ] && EMOJI="🧪" - - EDD_STATUS="✅" - [ "$EDD_RESULT" != "success" ] && EDD_STATUS="⚠️" - GDRIVE_STATUS="✅" - [ "$GDRIVE_RESULT" != "success" ] && GDRIVE_STATUS="⚠️" - - # Convert Markdown to Slack mrkdwn. - CONVERTED=$(echo "$CHANGELOG" \ - | sed 's/\*\*\([^*]*\)\*\*/*\1*/g' \ - | sed 's/\[\([^]]*\)\](\([^)]*\))/<\2|\1>/g') - - # Truncate to 10 lines. If longer, add "view full" note. - TOTAL_LINES=$(echo "$CONVERTED" | wc -l) - TRUNCATED=$(echo "$CONVERTED" | head -10) - WAS_TRUNCATED="false" - [ "$TOTAL_LINES" -gt 10 ] && WAS_TRUNCATED="true" - - CHANGELOG_URL="https://github.com/${REPO}/blob/master/CHANGELOG.md" - - # Use jq for safe JSON — all newlines handled inside jq template. - PAYLOAD=$(jq -n \ - --arg emoji "$EMOJI" \ - --arg plugin "$PLUGIN_NAME" \ - --arg version "$VERSION" \ - --arg changelog "$TRUNCATED" \ - --argjson more "$WAS_TRUNCATED" \ - --arg release_url "$RELEASE_URL" \ - --arg changelog_url "$CHANGELOG_URL" \ - --arg drive_url "$DRIVE_DOWNLOAD_URL" \ - --arg edd_status "$EDD_STATUS" \ - --arg gdrive_status "$GDRIVE_STATUS" \ - --argjson is_test "$( [ "$IS_TEST" == "true" ] && echo true || echo false )" \ - '{ - text: ( - "\($emoji) *\($plugin) \($version)*\n\n" - + "\($changelog)\n" - + (if $more then "_<\($changelog_url)|…see full changelog>_\n" else "" end) - + "\n*Downloads:*\n" - + "📖 <\($release_url)|GitHub Release>\n" - + (if $drive_url != "" then "⬇️ <\($drive_url)|Download Zip (shareable)>\n" else "" end) - + "📋 <\($changelog_url)|Changelog>\n" - + "\n*Status:* EDD \($edd_status) | GDrive \($gdrive_status)" - + (if $is_test then "\n_🧪 Test release — not published to WordPress.org_" else "" end) - ), - username: "Release Bot", - icon_emoji: ":package:" - }') - - curl -s -X POST "$SLACK_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - --fail --show-error - - echo "✅ Slack sent" - - slack-notification-failure: - name: Slack Failure Notification - runs-on: ubuntu-latest - needs: [build, github-release, edd-webhook, google-drive-upload] - if: | - always() && - needs.build.result == 'success' && - (needs.github-release.result == 'failure' || needs.edd-webhook.result == 'failure' || needs.google-drive-upload.result == 'failure') - - steps: - - name: Send failure notification - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - PLUGIN_NAME: ${{ env.PLUGIN_NAME }} - VERSION: ${{ needs.build.outputs.version }} - GH_RESULT: ${{ needs.github-release.result }} - EDD_RESULT: ${{ needs.edd-webhook.result }} - GDRIVE_RESULT: ${{ needs.google-drive-upload.result }} - RUN_ID: ${{ github.run_id }} - REPO: ${{ github.repository }} - run: | - if [ -z "$SLACK_WEBHOOK_URL" ]; then - exit 0 - fi - - GH_STATUS="✅" - [ "$GH_RESULT" != "success" ] && GH_STATUS="❌" - EDD_STATUS="✅" - [ "$EDD_RESULT" != "success" ] && EDD_STATUS="❌" - GDRIVE_STATUS="✅" - [ "$GDRIVE_RESULT" != "success" ] && GDRIVE_STATUS="❌" - - MESSAGE="*🚨 RELEASE FAILURE: ${PLUGIN_NAME} ${VERSION}*\n\n" - MESSAGE="${MESSAGE}• *GitHub:* ${GH_STATUS}\n" - MESSAGE="${MESSAGE}• *EDD:* ${EDD_STATUS}\n" - MESSAGE="${MESSAGE}• *Drive:* ${GDRIVE_STATUS}\n\n" - MESSAGE="${MESSAGE}🔍 " - - curl -X POST \ - --data-urlencode "payload={\"text\": \"${MESSAGE}\", \"username\": \"Release Bot\", \"icon_emoji\": \":package:\"}" \ - "$SLACK_WEBHOOK_URL" \ - --fail --silent --show-error - - # ============================================================================ - # RELEASE SUMMARY - # ============================================================================ - summary: - name: Release Summary - runs-on: ubuntu-latest - needs: [build, github-release, edd-webhook, google-drive-upload, slack-notification-success, slack-notification-failure] - if: always() - - steps: - - name: Generate summary - env: - VERSION: ${{ needs.build.outputs.version }} - IS_TEST: ${{ needs.build.outputs.is_test }} - IS_PRERELEASE: ${{ needs.build.outputs.is_prerelease }} - RELEASE_URL: ${{ needs.github-release.outputs.release_url }} - BUILD_RESULT: ${{ needs.build.result }} - GH_RESULT: ${{ needs.github-release.result }} - EDD_RESULT: ${{ needs.edd-webhook.result }} - GDRIVE_RESULT: ${{ needs.google-drive-upload.result }} - SLACK_OK: ${{ needs.slack-notification-success.result }} - SLACK_FAIL: ${{ needs.slack-notification-failure.result }} - run: | - TYPE="🚀 Stable" - [ "$IS_TEST" == "true" ] && TYPE="🧪 Test Lane" - [ "$IS_PRERELEASE" == "true" ] && TYPE="🔶 Pre-release" - - echo "# Release Summary: ${{ env.PLUGIN_NAME }} ${VERSION}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Type:** ${TYPE}" >> $GITHUB_STEP_SUMMARY - echo "**Release:** ${RELEASE_URL}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - [ "$BUILD_RESULT" == "success" ] && echo "✅ **Build**: Package created" >> $GITHUB_STEP_SUMMARY || echo "❌ **Build**: Failed" >> $GITHUB_STEP_SUMMARY - [ "$GH_RESULT" == "success" ] && echo "✅ **GitHub Release**: Created" >> $GITHUB_STEP_SUMMARY || echo "❌ **GitHub Release**: Failed" >> $GITHUB_STEP_SUMMARY - [ "$EDD_RESULT" == "success" ] && echo "✅ **EDD Webhook**: Delivered" >> $GITHUB_STEP_SUMMARY || echo "⚠️ **EDD Webhook**: ${EDD_RESULT}" >> $GITHUB_STEP_SUMMARY - [ "$GDRIVE_RESULT" == "success" ] && echo "✅ **Google Drive**: Uploaded" >> $GITHUB_STEP_SUMMARY || echo "⚠️ **Google Drive**: ${GDRIVE_RESULT}" >> $GITHUB_STEP_SUMMARY - [ "$SLACK_OK" == "success" ] && echo "✅ **Slack**: Notified" >> $GITHUB_STEP_SUMMARY || [ "$SLACK_FAIL" == "success" ] && echo "⚠️ **Slack**: Failure notice sent" >> $GITHUB_STEP_SUMMARY || echo "⚠️ **Slack**: ${SLACK_OK}" >> $GITHUB_STEP_SUMMARY - - echo "" >> $GITHUB_STEP_SUMMARY - echo "*WordPress.org deploy triggers separately on merge to master.*" >> $GITHUB_STEP_SUMMARY + product-key: core + expected-repository: PopupMaker/Popup-Maker + release-id: ${{ format('{0}', needs.github-release.outputs.release_id) }} + secrets: + WORDPRESS_URL: ${{ secrets.WORDPRESS_URL }} + WORDPRESS_ACCESS_TOKEN: ${{ secrets.WORDPRESS_ACCESS_TOKEN }} + WORDPRESS_USERNAME: ${{ secrets.WORDPRESS_USERNAME }} + WORDPRESS_APPLICATION_PASSWORD: ${{ secrets.WORDPRESS_APPLICATION_PASSWORD }} + + back-sync: + name: Open master back-sync PR + needs: [authorize, github-release] + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Open or reuse master to develop PR + id: back_sync + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + VERSION: ${{ needs.authorize.outputs.version }} + run: | + EXISTING=$(gh pr list --repo "${REPO}" --base develop --head master --state open --json url --jq '.[0].url // ""') + if [ -n "${EXISTING}" ]; then + echo "Back-sync PR already open: ${EXISTING}" + exit 0 + fi + + gh pr create \ + --repo "${REPO}" \ + --base develop \ + --head master \ + --title "Back-sync release ${VERSION} to develop" \ + --body "Carries the approved ${VERSION} release metadata and release merge back to develop." + + slack-success: + name: Slack success notification + needs: + [ + authorize, + build, + github-release, + wordpress-org, + edd-webhook, + google-drive-upload, + changelog, + back-sync, + ] + if: >- + always() && + needs.github-release.result == 'success' && + needs.wordpress-org.result == 'success' && + needs.edd-webhook.result == 'success' && + needs.google-drive-upload.result == 'success' && + needs.changelog.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Send success notification + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + VERSION: ${{ needs.authorize.outputs.version }} + RELEASE_URL: ${{ needs.github-release.outputs.release_url }} + DRIVE_URL: ${{ needs.google-drive-upload.outputs.download_link }} + run: | + if [ -z "${SLACK_WEBHOOK_URL}" ]; then + exit 0 + fi + PAYLOAD=$(jq -n \ + --arg version "${VERSION}" \ + --arg release_url "${RELEASE_URL}" \ + --arg drive_url "${DRIVE_URL}" \ + '{text: ("🚀 *Popup Maker " + $version + " released*\n\n✅ GitHub release\n✅ WordPress.org\n✅ EDD\n✅ Google Drive\n✅ Changelog draft\n\n<" + $release_url + "|View release>" + (if $drive_url != "" then " · <" + $drive_url + "|Download ZIP>" else "" end)), username: "Release Bot", icon_emoji: ":package:"}') + curl -sS --fail -X POST "${SLACK_WEBHOOK_URL}" \ + -H 'Content-Type: application/json' \ + -d "${PAYLOAD}" + + slack-failure: + name: Slack failure notification + needs: + [ + authorize, + build, + github-release, + wordpress-org, + edd-webhook, + google-drive-upload, + changelog, + back-sync, + ] + if: >- + always() && + needs.authorize.result != 'skipped' && + (needs.authorize.result == 'failure' || + needs.build.result == 'failure' || + needs.github-release.result == 'failure' || + needs.wordpress-org.result == 'failure' || + needs.edd-webhook.result == 'failure' || + needs.google-drive-upload.result == 'failure' || + needs.changelog.result == 'failure') + runs-on: ubuntu-latest + steps: + - name: Send failure notification + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + VERSION: ${{ needs.authorize.outputs.version }} + RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ -z "${SLACK_WEBHOOK_URL}" ]; then + exit 0 + fi + PAYLOAD=$(jq -n \ + --arg version "${VERSION:-unknown}" \ + --arg run_url "${RUN_URL}" \ + '{text: ("🚨 *Popup Maker " + $version + " release needs attention*\n\n<" + $run_url + "|View failed workflow>"), username: "Release Bot", icon_emoji: ":package:"}') + curl -sS --fail -X POST "${SLACK_WEBHOOK_URL}" \ + -H 'Content-Type: application/json' \ + -d "${PAYLOAD}" + + summary: + name: Release summary + needs: + [ + authorize, + build, + github-release, + wordpress-org, + edd-webhook, + google-drive-upload, + changelog, + back-sync, + slack-success, + slack-failure, + ] + if: always() && needs.authorize.result != 'skipped' + runs-on: ubuntu-latest + + steps: + - name: Write release summary + env: + VERSION: ${{ needs.authorize.outputs.version }} + PR_NUMBER: ${{ needs.authorize.outputs.pull_request_number }} + RELEASE_URL: ${{ needs.github-release.outputs.release_url }} + BUILD_RESULT: ${{ needs.build.result }} + GITHUB_RESULT: ${{ needs.github-release.result }} + WORDPRESS_RESULT: ${{ needs.wordpress-org.result }} + EDD_RESULT: ${{ needs.edd-webhook.result }} + DRIVE_RESULT: ${{ needs.google-drive-upload.result }} + CHANGELOG_RESULT: ${{ needs.changelog.result }} + BACK_SYNC_RESULT: ${{ needs.back-sync.result }} + run: | + { + echo "# Popup Maker ${VERSION}" + echo "Approved release PR: #${PR_NUMBER}" + echo "Release: ${RELEASE_URL}" + echo "" + echo "| Step | Result |" + echo "| --- | --- |" + echo "| Build | ${BUILD_RESULT} |" + echo "| GitHub | ${GITHUB_RESULT} |" + echo "| WordPress.org | ${WORDPRESS_RESULT} |" + echo "| EDD | ${EDD_RESULT} |" + echo "| Google Drive | ${DRIVE_RESULT} |" + echo "| Changelog draft | ${CHANGELOG_RESULT} |" + echo "| Back-sync PR | ${BACK_SYNC_RESULT} |" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.release-please-config.json b/.release-please-config.json deleted file mode 100644 index e3cb294d1..000000000 --- a/.release-please-config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "simple", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": false, - "draft": false, - "prerelease": false, - "packages": { - ".": { - "component": "popup-maker", - "changelog-path": "changelog.txt", - "changelog-type": "wordpress" - } - } -} diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 98fb2bf19..000000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "1.21.5" -} diff --git a/bin/README.md b/bin/README.md index 7d00c63d5..59856492d 100644 --- a/bin/README.md +++ b/bin/README.md @@ -2,12 +2,12 @@ This directory contains tools for managing WordPress plugin releases: -- **prepare-release.js** - Automates the complete release workflow with git flow -- **build-release.js** - Unified build script for creating release packages +- **prepare-release.js** - Prepares and opens a reviewed release PR +- **build-release.js** - Unified build script for creating release packages ## Features -- **Unified Process**: Single script handles the entire release workflow +- **Reviewed Publication**: A PR approval and merge is the release gate - **Configurable**: Supports multiple configuration options and flags - **Cross-Plugin**: Can be copied and used across all your plugins - **Smart Detection**: Automatically reads plugin name and version from `package.json` @@ -19,50 +19,50 @@ This directory contains tools for managing WordPress plugin releases: ### Quick Start -The **prepare-release.js** script automates the complete release workflow including version management, changelog updates, and git flow integration. +The **prepare-release.js** script prepares a release branch, package, and PR. It never merges, tags, or publishes directly. ```bash # Patch release (1.21.4 → 1.21.5) -node bin/prepare-release.js +pnpm run prepare-release start # Minor release (1.21.4 → 1.22.0) -node bin/prepare-release.js --minor +pnpm run prepare-release start -- --minor # Major release (1.21.4 → 2.0.0) -node bin/prepare-release.js --major +pnpm run prepare-release start -- --major # Specific version -node bin/prepare-release.js 2.1.0 +pnpm run prepare-release start -- 2.1.0 # Test without changes -node bin/prepare-release.js --dry-run +pnpm run prepare-release start -- --dry-run # See all options -node bin/prepare-release.js --help +pnpm run prepare-release -- --help ``` ### What It Does -1. ✅ Validates git status and git flow availability -2. 🌿 Creates git flow release branch +1. ✅ Validates git status and release inputs +2. 🌿 Creates `release/X.Y.Z` 3. 📝 Updates versions in all files (via `update-versions.js`) 4. 📋 Updates changelog (via `update-changelog.js`) -5. 📦 Updates `package-lock.json` +5. 📦 Updates `pnpm-lock.yaml` 6. 🔨 Builds release assets (`pnpm run release`) 7. 💾 Commits changes with standardized message -8. 🏁 Finishes git flow release with tag -9. 🚀 Offers to push changes +8. 🚀 Pushes the branch and opens its PR to `master` +9. 🛑 Leaves approval, merge, tagging, and publication to GitHub ### Options -- `[version]` - Specific version number (e.g., `1.21.5`) -- `--major` - Increment major version (X+1.0.0) -- `--minor` - Increment minor version (X.Y+1.0) -- `--patch` - Increment patch version (X.Y.Z+1) [default] -- `--dry-run` - Show what would be done without making changes -- `--no-build` - Skip the release build step -- `--auto` - Skip all confirmations (dangerous!) -- `--help` - Show detailed help +- `[version]` - Specific version number (e.g., `1.21.5`) +- `--major` - Increment major version (X+1.0.0) +- `--minor` - Increment minor version (X.Y+1.0) +- `--patch` - Increment patch version (X.Y.Z+1) [default] +- `--dry-run` - Show what would be done without making changes +- `--skip-build` - Skip the release build step +- `--auto` - Skip local confirmations; PR approval is still required +- `--help` - Show detailed help ## Build Release Script @@ -209,10 +209,11 @@ By default, the script creates zip files with the format: `{plugin-name}_{versio You can customize this for special cases: **Use Cases for Custom Zip Names:** -- **Beta/RC releases**: `--zip-name my-plugin-v1.2.0-beta1.zip` -- **Client-specific builds**: `--zip-name my-plugin-client-custom.zip` -- **Distribution channels**: `--zip-name my-plugin-wordpress-org.zip` -- **Build variants**: `--zip-name my-plugin-lite-v1.0.0.zip` + +- **Beta/RC releases**: `--zip-name my-plugin-v1.2.0-beta1.zip` +- **Client-specific builds**: `--zip-name my-plugin-client-custom.zip` +- **Distribution channels**: `--zip-name my-plugin-wordpress-org.zip` +- **Build variants**: `--zip-name my-plugin-lite-v1.0.0.zip` ## Example Workflows @@ -294,14 +295,5 @@ If you have existing release scripts, you can gradually migrate: } ``` -### Git Hooks - -Add to your `package.json` for automatic tagging: - -```json -{ - "scripts": { - "release": "node bin/build-release.js && git tag v$npm_package_version && git push --tags" - } -} -``` +Do not add local tag or push hooks. Production tags are created only after an +approved `release/X.Y.Z` PR is merged. diff --git a/bin/prepare-release.js b/bin/prepare-release.js index 3d408f901..0a60d1ed0 100755 --- a/bin/prepare-release.js +++ b/bin/prepare-release.js @@ -7,14 +7,13 @@ * Stage 1 - Prepare release: * node bin/prepare-release.js start [version] [options] * - * Stage 2 - Finish release: + * Stage 2 - Open release PR: * node bin/prepare-release.js finish [options] * * Flags: * --major, --minor, --patch Version increment type (start only) * --skip-tests Bypass CI checks (start only) * --skip-build Skip build step (start only) - * --test Create test tag (finish only) * --auto Skip confirmations * --dry-run Show what would happen without changes */ @@ -87,10 +86,9 @@ ${ colorize( 'cyan', 'STAGE 1 - START:' ) } node bin/prepare-release.js start --dry-run # Preview only ${ colorize( 'cyan', 'STAGE 2 - FINISH:' ) } - Merge to master, tag, merge back to develop, push. + Push the release branch and open its reviewed PR to master. - node bin/prepare-release.js finish # Create stable tag - node bin/prepare-release.js finish --test # Create -test tag + node bin/prepare-release.js finish # Open release PR node bin/prepare-release.js finish --auto # No prompts node bin/prepare-release.js finish --dry-run # Preview only @@ -101,9 +99,9 @@ ${ colorize( 'cyan', 'AUTO MODE:' ) } - Otherwise → shows help ${ colorize( 'cyan', 'WORKFLOW:' ) } - 1. npm run prepare-release start # Prepare on release branch + 1. pnpm run prepare-release start # Prepare on release branch 2. [Review zip in release/ folder] - 3. npm run prepare-release finish # Merge, tag, push + 3. pnpm run prepare-release finish # Push and open release PR ` ); process.exit( 0 ); } @@ -217,7 +215,9 @@ function prompt( question ) { // Get current branch. function getCurrentBranch() { - const branch = execCommand( 'git rev-parse --abbrev-ref HEAD', { silent: true } ); + const branch = execCommand( 'git rev-parse --abbrev-ref HEAD', { + silent: true, + } ); return branch.trim(); } @@ -236,10 +236,7 @@ function checkGitStatus() { // Stage 1: START async function stageStart( targetVersion ) { - log( - colorize( 'bold', '🚀 Starting Release Preparation' ), - 'magenta' - ); + log( colorize( 'bold', '🚀 Starting Release Preparation' ), 'magenta' ); console.log( '' ); checkProjectRoot(); @@ -279,9 +276,12 @@ async function stageStart( targetVersion ) { const checks = [ { name: 'PHPCS Lint', cmd: 'composer run lint --quiet' }, - { name: 'ESLint', cmd: 'npx eslint ./packages/**/src/*.ts* --no-ignore --quiet' }, - { name: 'Unit Tests', cmd: 'npm run test:unit' }, - { name: 'Security Audit', cmd: 'npm audit --audit-level=high' }, + { + name: 'ESLint', + cmd: 'pnpm exec eslint ./packages/**/src/*.ts* --no-ignore --quiet', + }, + { name: 'Unit Tests', cmd: 'pnpm run test:unit' }, + { name: 'Security Audit', cmd: 'pnpm audit --audit-level=high' }, ]; for ( const check of checks ) { @@ -309,15 +309,15 @@ async function stageStart( targetVersion ) { execCommand( `node bin/update-changelog.js ${ targetVersion }` ); success( 'Changelog updated' ); - // Update package-lock.json. - log( 'Updating package-lock.json', 'cyan' ); - execCommand( 'npm install --package-lock-only' ); - success( 'Package lock updated' ); + // Update pnpm-lock.yaml. + log( 'Updating pnpm-lock.yaml', 'cyan' ); + execCommand( 'pnpm install --lockfile-only' ); + success( 'pnpm lockfile updated' ); // Build release (unless --skip-build). if ( ! argv[ 'skip-build' ] ) { log( 'Building release assets', 'cyan' ); - execCommand( 'npm run build:production' ); + execCommand( 'pnpm run build:production' ); execCommand( 'node bin/build-release.js' ); success( 'Release assets built' ); } else { @@ -333,43 +333,48 @@ async function stageStart( targetVersion ) { success( `Committed release preparation` ); console.log( '' ); - success( `✅ Release ${ targetVersion } prepared on branch release/${ targetVersion }` ); + success( + `✅ Release ${ targetVersion } prepared on branch release/${ targetVersion }` + ); console.log( '' ); log( 'Next steps:', 'cyan' ); info( ' • Inspect the release zip in release/' ); info( ' • Update readme.txt if needed' ); info( ' • Commit any additional changes' ); - info( ` • Ship it: npm run prepare-release finish` ); - info( ` • Test it: npm run prepare-release finish -- --test` ); + info( ` • Open the release PR: pnpm run prepare-release finish` ); } // Stage 2: FINISH async function stageFinish() { - log( - colorize( 'bold', '🚀 Finishing Release' ), - 'magenta' - ); + log( colorize( 'bold', '🚀 Finishing Release' ), 'magenta' ); console.log( '' ); checkProjectRoot(); checkGitStatus(); const currentBranch = getCurrentBranch(); - const releaseMatch = currentBranch.match( /^release\/(.+)$/ ); + const releaseMatch = currentBranch.match( /^release\/(\d+\.\d+\.\d+)$/ ); if ( ! releaseMatch ) { - error( `Must be on release/* branch. Currently on: ${ currentBranch }` ); + error( + `Must be on release/* branch. Currently on: ${ currentBranch }` + ); process.exit( 1 ); } const version = releaseMatch[ 1 ]; - const isTest = argv.test; - const tagSuffix = isTest ? '-test' : ''; - const tag = `${ version }${ tagSuffix }`; - log( `Version: ${ version }`, 'yellow' ); - log( `Tag: ${ tag }`, 'green' ); console.log( '' ); + execCommand( + `node bin/validate-release-version.js --version ${ version }` + ); + + if ( argv.test ) { + error( + 'Direct test tags are disabled. Use the release PR artifact for testing.' + ); + process.exit( 1 ); + } if ( dryRun ) { warn( 'DRY RUN MODE - No changes will be made' ); @@ -377,7 +382,9 @@ async function stageFinish() { } // Confirmation. - const confirmed = await prompt( `Create ${ isTest ? 'test' : 'stable' } release ${ tag }?` ); + const confirmed = await prompt( + `Push release/${ version } and open its PR to master?` + ); if ( ! confirmed ) { info( 'Release cancelled by user' ); process.exit( 0 ); @@ -385,56 +392,35 @@ async function stageFinish() { console.log( '' ); - if ( isTest ) { - // Test mode: tag from release branch, push tag only. No merge. - log( `Creating test tag from release branch: ${ tag }`, 'cyan' ); - execCommand( `git tag ${ tag }` ); - success( `Tag created: ${ tag }` ); + log( `Pushing release/${ version }`, 'cyan' ); + execCommand( `git push --set-upstream origin release/${ version }` ); - log( 'Pushing test tag to remote', 'cyan' ); - execCommand( `git push origin ${ tag }` ); - success( 'Test tag pushed' ); - } else { - // Stable release: full merge flow. - log( 'Merging to master branch', 'cyan' ); - execCommand( 'git checkout master' ); - execCommand( `git merge --no-ff release/${ version } -m "Merge release ${ version }"` ); - success( 'Merged to master' ); - - // Create tag on master. - log( `Creating tag: ${ tag }`, 'cyan' ); - execCommand( `git tag ${ tag }` ); - success( `Tag created: ${ tag }` ); - - // Merge back to develop. - log( 'Merging back to develop branch', 'cyan' ); - execCommand( 'git checkout develop' ); - execCommand( `git merge --no-ff master -m "Merge release ${ version } back to develop"` ); - success( 'Merged back to develop' ); - - // Delete release branch. - log( `Deleting release branch: release/${ version }`, 'cyan' ); - execCommand( `git branch -d release/${ version }` ); - success( 'Release branch deleted' ); - - // Push everything. - log( 'Pushing to remote', 'cyan' ); - execCommand( 'git push origin master develop --tags' ); - success( 'Pushed to remote' ); + const hasGitHubCli = execCommand( 'command -v gh', { + silent: true, + allowFailure: true, + } ); + if ( ! hasGitHubCli ) { + warn( + 'GitHub CLI is unavailable. Open a PR from the release branch to master.' + ); + return; } - console.log( '' ); - if ( isTest ) { - log( `🧪 Test tag ${ tag } pushed. GitHub Actions will dry-run the release pipeline.`, 'cyan' ); - console.log( '' ); - info( 'Next steps:' ); - info( ' 1. Watch GitHub Actions for pipeline results' ); - info( ' 2. Verify draft release, SVN dry-run, Slack notification' ); - info( ' 3. Clean up test tag: git tag -d ' + tag + ' && git push origin :refs/tags/' + tag ); - info( ' 4. Ship for real: npm run prepare-release finish' ); - } else { - log( `🚀 Release ${ tag } shipped! GitHub Actions will handle the rest.`, 'cyan' ); + const existingPullRequest = execCommand( + 'gh pr view --json url --jq .url', + { silent: true, allowFailure: true } + ); + if ( existingPullRequest ) { + success( `Release PR already exists: ${ existingPullRequest.trim() }` ); + return; } + + const pullRequestUrl = execCommand( + `gh pr create --base master --head release/${ version } --title "Release ${ version }" --body "Approve and merge this PR to publish Popup Maker ${ version } through GitHub, EDD, Google Drive, WordPress.org, the visual changelog draft, and Slack."`, + { silent: true } + ); + success( `Release PR opened: ${ pullRequestUrl.trim() }` ); + info( 'Nothing publishes until the PR is approved and merged.' ); } // Auto-detect command based on branch. @@ -452,8 +438,8 @@ async function autoDetect() { await stageStart( targetVersion ); } else { error( 'Unknown state. Please specify start or finish:' ); - info( ' npm run prepare-release start # Prepare new release' ); - info( ' npm run prepare-release finish # Finish release' ); + info( ' pnpm run prepare-release start # Prepare new release' ); + info( ' pnpm run prepare-release finish # Open release PR' ); process.exit( 1 ); } } @@ -492,3 +478,5 @@ process.on( 'SIGINT', () => { if ( require.main === module ) { main(); } + +/* eslint-enable no-console */ diff --git a/bin/validate-release-version.js b/bin/validate-release-version.js new file mode 100644 index 000000000..d311b16d5 --- /dev/null +++ b/bin/validate-release-version.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +const fs = require( 'fs' ); +const path = require( 'path' ); + +function readFile( projectRoot, fileName ) { + return fs.readFileSync( path.join( projectRoot, fileName ), 'utf8' ); +} + +function extractMatch( contents, pattern, label ) { + const match = contents.match( pattern ); + + if ( ! match ) { + throw new Error( `Could not read ${ label }.` ); + } + + return match[ 1 ]; +} + +function compareVersions( left, right ) { + const leftParts = left.split( '.' ).map( Number ); + const rightParts = right.split( '.' ).map( Number ); + + for ( let index = 0; index < 3; index++ ) { + if ( leftParts[ index ] !== rightParts[ index ] ) { + return leftParts[ index ] - rightParts[ index ]; + } + } + + return 0; +} + +function validateReleaseVersion( { + projectRoot = process.cwd(), + version, + previousVersion, +} ) { + if ( ! /^\d+\.\d+\.\d+$/.test( version || '' ) ) { + throw new Error( 'Release version must use stable X.Y.Z format.' ); + } + + if ( + previousVersion && + ( ! /^\d+\.\d+\.\d+$/.test( previousVersion ) || + compareVersions( version, previousVersion ) <= 0 ) + ) { + throw new Error( + `Release ${ version } must be newer than ${ previousVersion }.` + ); + } + + const packageVersion = JSON.parse( + readFile( projectRoot, 'package.json' ) + ).version; + const pluginContents = readFile( projectRoot, 'popup-maker.php' ); + const readmeContents = readFile( projectRoot, 'readme.txt' ); + const changelogContents = readFile( projectRoot, 'CHANGELOG.md' ); + const versions = { + 'package.json': packageVersion, + 'popup-maker.php header': extractMatch( + pluginContents, + /^\s*\*\s*Version:\s*([^\s]+)\s*$/m, + 'plugin header version' + ), + 'popup-maker.php config': extractMatch( + pluginContents, + /'version'\s*=>\s*'([^']+)'/, + 'plugin config version' + ), + 'readme.txt stable tag': extractMatch( + readmeContents, + /^Stable tag:\s*([^\s]+)\s*$/m, + 'readme stable tag' + ), + }; + const mismatches = Object.entries( versions ).filter( + ( [ , foundVersion ] ) => foundVersion !== version + ); + + if ( mismatches.length ) { + throw new Error( + `Version mismatch: ${ mismatches + .map( + ( [ label, foundVersion ] ) => + `${ label }=${ foundVersion }` + ) + .join( ', ' ) }; expected ${ version }.` + ); + } + + const escapedVersion = version.replace( /\./g, '\\.' ); + const datedHeading = `\\d{4}-\\d{2}-\\d{2}`; + + if ( + ! new RegExp( + `^## v${ escapedVersion } - ${ datedHeading }$`, + 'm' + ).test( changelogContents ) + ) { + throw new Error( `CHANGELOG.md has no dated v${ version } entry.` ); + } + + if ( + ! new RegExp( + `^= ${ escapedVersion } - ${ datedHeading } =$`, + 'm' + ).test( readmeContents ) + ) { + throw new Error( + `readme.txt has no dated ${ version } changelog entry.` + ); + } + + return versions; +} + +function getArgument( name ) { + const index = process.argv.indexOf( name ); + return index === -1 ? '' : process.argv[ index + 1 ] || ''; +} + +if ( require.main === module ) { + try { + const version = getArgument( '--version' ); + const previousVersion = getArgument( '--previous-version' ); + validateReleaseVersion( { version, previousVersion } ); + console.log( + `Release version ${ version } is consistent${ + previousVersion ? ` and newer than ${ previousVersion }` : '' + }.` + ); + } catch ( error ) { + console.error( error.message ); + process.exit( 1 ); + } +} + +module.exports = { + compareVersions, + validateReleaseVersion, +}; + +/* eslint-enable no-console */ diff --git a/docs/github-actions.md b/docs/github-actions.md index eedb2c8c7..90c53a7e2 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -1,521 +1,66 @@ -# GitHub Actions Workflows Documentation +# GitHub Actions -## Overview +Popup Maker uses reviewed pull requests as the only production publication gate. -Popup Maker uses GitHub Actions to automate development, testing, and release processes. Our workflows follow industry best practices with proper security, environment protection, and comprehensive error handling. +## Full releases -## Workflow Architecture +1. Prepare `release/X.Y.Z` from `develop`. +2. Update the plugin versions and dated changelogs. +3. Open the PR against `master` with `pnpm run prepare-release finish`. +4. Review the candidate ZIP and required checks in the PR. +5. Approve and merge the PR. -### 🔨 Build Test Package (`build.yml`) -**Purpose**: Creates development test builds with optional quality checks and Slack notifications. +The merged PR is re-authorized before any external write. It must: -**When to Use**: -- Testing changes on specific branches/commits before releases -- Creating development packages for QA testing -- Running quality checks (linting, tests) on demand -- Getting packages for stakeholder review +- be merged into `master`; +- come from this repository; +- have a current maintainer approval or be merged by an authorized maintainer; +- use the exact `release/X.Y.Z` branch name; +- advance every canonical version field together; +- include dated `CHANGELOG.md` and `readme.txt` entries; and +- not reuse a version tag that points to another commit. -**Security**: No approval required - available to all team members +After those checks pass, `release.yml` builds one canonical ZIP and uses that same artifact for: -**Trigger Options**: -- **Manual**: Go to Actions → Build Test Package → "Run workflow" -- **API**: Repository dispatch with `build-test` type +- the version tag and GitHub Release; +- the EDD release record; +- Google Drive; +- WordPress.org SVN; +- the review-required visual changelog draft; and +- Slack status. -**Configuration Options**: -| Option | Description | Default | Use Case | -|--------|-------------|---------|----------| -| **Source Branch/Tag** | Which code to build | `develop` | Test feature branches, specific commits | -| **Version Suffix** | Custom version identifier | Branch name + timestamp | Custom build naming | -| **Run Tests** | Execute test suite | Disabled | Quality assurance builds | -| **Run Linting** | Code quality checks | Disabled | Code review preparation | -| **Slack Notifications** | Team alerts | Disabled | Team collaboration | +It also attempts to open a `master` to `develop` back-sync PR. A failed downstream step is visible and can be retried by manually running the workflow with the original merged PR number. -**Process Flow**: -1. **Validation** → Extract plugin info, generate build version -2. **Quality Checks** → Optional linting and testing (parallel) -3. **Build Package** → Install dependencies, build assets, create ZIP -4. **Notifications** → Slack alerts with download links -5. **Summary** → Comprehensive build report +Direct tags, direct pushes to `master`, and unapproved PRs do not publish a plugin release. -**Outputs**: -- Plugin ZIP file with proper WordPress structure -- SHA256 checksum for verification -- 30-day artifact retention -- Detailed build summary with download instructions -- Changelog content extracted from recent changes +## WordPress.org readme and assets -**Performance Features**: -- ⚡ **NPM Cache**: 3-5 minute savings on dependencies -- ⚡ **Composer Cache**: 2-3 minute savings on PHP packages -- 🔄 **Parallel Processing**: Quality checks run independently +A PR containing only `readme.txt` and/or files below `.wordpress-org/` may be opened against `master`. After it is approved and merged, `deploy-readme-assets.yml` re-checks the approval and exact file list, then syncs only those files to WordPress.org. ---- +A mixed code/readme PR never enters this narrow path. Release PRs deploy their readme and assets with the full canonical package. -### 🚀 Release WordPress Plugin (`release.yml`) -**Purpose**: Creates official production releases following git-flow methodology. +## PR publication preview -**When to Use**: -- Publishing new versions to users -- Creating WordPress.org releases -- Following semantic versioning releases -- Production deployments +`publication-gate.yml` runs on every PR to `master` and states which result a merge would have: -**Security**: ⚠️ **Requires `production` environment approval** before execution +- full release; +- readme/assets-only update; or +- no publication. -**Trigger Options**: -- **Manual**: Actions → Release WordPress Plugin → "Run workflow" (requires approval) -- **API**: Repository dispatch with `create-release` type +Release PRs also build and verify a downloadable candidate ZIP before approval. -**Configuration Options**: -| Option | Description | Required | Example | -|--------|-------------|----------|---------| -| **Version** | Semantic version | ✅ | `1.19.0`, `2.0.0-beta.1` | -| **Changelog Message** | Additional release notes | ❌ | Custom release highlights | -| **Pre-release** | Skip master merge, tag develop only | ❌ | Beta/RC releases | -| **Deploy WordPress.org** | Automatic SVN deployment | ❌ | Production releases | -| **Dry-run SVN** | Test deployment without committing | ❌ | Deployment testing | -| **Skip Quality Checks** | Emergency releases only | ❌ | Critical hotfixes | +## Development builds -**Release Types**: +Use `build.yml` manually for QA packages from a branch, tag, or commit. These artifacts never publish to a production channel. -**Production Release** (default): -``` -develop → release/X.X.X → master → tag → WordPress.org -``` +## Changelog retries and edits -**Pre-release**: -``` -develop → release/X.X.X → tag develop (skip master) -``` +`changelog-sync.yml` remains available for GitHub Release edits and explicit retries. The full release workflow calls the same pinned, draft-first sync directly because actions created with `GITHUB_TOKEN` do not reliably trigger another workflow. -**Git Flow Process**: -1. **Validation** → Version format, branch existence, environment approval -2. **Quality Checks** → Optional linting and testing -3. **Git Flow** → Create release branch, update versions, merge strategy -4. **Build Package** → Production build from release tag -5. **GitHub Release** → Create release with artifacts -6. **WordPress.org Deploy** → Optional SVN deployment -7. **Notifications** → Comprehensive team alerts +## Security boundaries -**File Updates During Release**: -- `popup-maker.php` → Plugin header version -- `package.json` → NPM version -- `readme.txt` → Stable tag -- `CHANGELOG.md` → Release entry with date -- All version references updated automatically - -**Outputs**: -- GitHub release with plugin ZIP and checksum -- WordPress.org deployment (if enabled) -- Updated master and develop branches -- Comprehensive Slack notifications with download links - ---- - -### 🩹 Hotfix Release (`hotfix.yml`) -**Purpose**: Emergency fixes directly from master branch for critical issues. - -**When to Use**: -- Critical security vulnerabilities -- Production-breaking bugs -- Urgent fixes that can't wait for normal release cycle - -**Security**: ⚠️ **Requires `production` environment approval** - -**Two-Phase Process**: - -#### Phase 1: Create Hotfix Branch -**Action**: `create` -``` -Actions → Hotfix WordPress Plugin -- Hotfix Action: create -- Version: 1.18.1 -- Source Branch: master -``` - -**Process**: -1. Creates `hotfix/1.18.1` branch from master -2. Updates version numbers in all files -3. Commits preparation changes -4. Pushes branch for development -5. Sends Slack notification with next steps - -#### Phase 2: Complete Hotfix -**Action**: `complete` (after applying fixes) -``` -Actions → Hotfix WordPress Plugin -- Hotfix Action: complete -- Hotfix Branch: hotfix/1.18.1 -- Deploy WordPress.org: ✅ -``` - -**Process**: -1. Merges hotfix branch to master -2. Tags the release -3. Merges master back to develop (sync) -4. Creates GitHub release -5. Optionally deploys to WordPress.org -6. Cleans up hotfix branch - -**Configuration Options**: -| Option | Description | Required | Phase | -|--------|-------------|----------|-------| -| **Hotfix Action** | `create` or `complete` | ✅ | Both | -| **Version** | Hotfix version (e.g., 1.19.1) | ✅ | Create | -| **Hotfix Branch** | Existing branch name | ✅ | Complete | -| **Source Branch** | Usually master | ❌ | Create | -| **Changelog Message** | Hotfix description | ❌ | Both | -| **Deploy WordPress.org** | SVN deployment | ❌ | Complete | -| **Dry-run SVN** | Test deployment | ❌ | Complete | -| **Skip Quality Checks** | Emergency use only | ❌ | Create | - -**Workflow Example**: -```bash -# 1. Create hotfix branch -Actions → Hotfix → create → version: 1.18.1 - -# 2. Apply your fixes to hotfix/1.18.1 branch -git checkout hotfix/1.18.1 -# ... make fixes ... -git commit -m "fix: critical security issue" - -# 3. Complete the hotfix -Actions → Hotfix → complete → branch: hotfix/1.18.1 -``` - ---- - -## Quick Usage Guide - -### For Developers - -**Testing Feature Branches**: -```bash -Actions → Build Test Package -- Branch: feature/my-feature -- Run Tests: ✅ -- Run Linting: ✅ -- Slack Notifications: ✅ -``` - -**Code Quality Checks**: -```bash -Actions → Build Test Package -- Branch: develop -- Run Linting: ✅ -- Run Tests: ✅ -``` - -**QA Testing Packages**: -```bash -Actions → Build Test Package -- Branch: develop -- Version Suffix: "qa-testing-round-2" -- Slack Notifications: ✅ -``` - -### For Release Managers - -**Standard Production Release**: -```bash -Actions → Release WordPress Plugin -- Version: 1.19.0 -- Deploy WordPress.org: ✅ -- Pre-release: ❌ -``` - -**Beta/Pre-release**: -```bash -Actions → Release WordPress Plugin -- Version: 1.19.0-beta.1 -- Pre-release: ✅ -- Deploy WordPress.org: ❌ -``` - -**Release Candidate**: -```bash -Actions → Release WordPress Plugin -- Version: 1.19.0-rc.1 -- Pre-release: ✅ -- Deploy WordPress.org: ❌ -``` - -### For Emergency Fixes - -**Critical Security Hotfix**: -```bash -# Step 1: Create hotfix branch -Actions → Hotfix Release -- Action: create -- Version: 1.18.1 -- Changelog: "Fix critical XSS vulnerability" - -# Step 2: Apply fixes to hotfix/1.18.1 - -# Step 3: Complete hotfix -Actions → Hotfix Release -- Action: complete -- Hotfix Branch: hotfix/1.18.1 -- Deploy WordPress.org: ✅ -``` - -**Production Bug Hotfix**: -```bash -# Step 1: Create hotfix branch -Actions → Hotfix Release -- Action: create -- Version: 1.18.2 -- Changelog: "Fix checkout process breaking on mobile" - -# Step 2: Apply fixes and test - -# Step 3: Complete hotfix -Actions → Hotfix Release -- Action: complete -- Hotfix Branch: hotfix/1.18.2 -- Deploy WordPress.org: ✅ -``` - ---- - -## Performance Optimizations - -### Intelligent Caching Strategy -Our workflows use optimized caching to significantly reduce build times: - -**High-Value Caches** (Kept): -- **NPM Dependencies**: 3-5 minute savings on subsequent builds - - Caches: `~/.npm`, `node_modules`, `packages/*/node_modules` - - Key: `npm-{OS}-{package-lock.json hash}` -- **Composer Dependencies**: 2-3 minute savings on PHP installs - - Caches: `~/.composer/cache`, `vendor` - - Key: `composer-{OS}-{composer.lock hash}` - -**Removed Caches** (Caused overhead): -- Build artifacts (prevented fresh builds) -- TypeScript output (added 20+ seconds) -- Webpack cache (inconsistent performance) -- Strauss vendor prefixing (potential for stale files) - -### Parallel Processing -- Quality checks run independently from builds -- Multiple dependency installations in parallel -- Optimized artifact handling reduces wait times - -### Smart Build Scripts -- Uses `bin/build-release.js` for consistent packaging -- Parallel composer and npm builds save ~40% build time -- Intelligent fallbacks for missing build tools - ---- - -## Security Features - -### Environment Protection -- **Production Environment**: Requires manual approval for releases and hotfixes -- **Separate Webhooks**: Different Slack channels for dev vs production notifications -- **Secret Management**: WordPress.org credentials stored securely -- **Token Scoping**: Minimal permissions for each workflow - -### Quality Gates -- **Optional Quality Checks**: Can skip for emergency releases -- **Semantic Version Validation**: Prevents invalid version formats -- **Branch Verification**: Ensures required branches exist -- **Build Artifact Checksums**: SHA256 verification for all packages - -### Approval Process -1. Release/Hotfix workflow triggered -2. GitHub requires production environment approval -3. Designated approvers must manually approve -4. Workflow proceeds with full audit trail - ---- - -## Notification System - -### Slack Integration - -**Build Test Notifications** (`SLACK_WEBHOOK_DEV`): -- 📦 Direct download links with login instructions -- 📝 Changelog highlights from recent changes -- 🔗 GitHub Actions and source branch links -- 📊 Build metrics (size, quality status, build time) -- ⚠️ Test build warnings with 30-day retention notice - -**Release Success Notifications** (`SLACK_WEBHOOK_SUCCESS`): -- 🎉 Release announcement with version and type -- 📦 GitHub release and WordPress.org links -- 📝 Full changelog content -- 📊 Package size and deployment status -- 🚀 Direct action buttons for easy access - -**Hotfix Notifications**: -- 🛠️ **Create Phase**: Branch ready, next steps, branch link -- 🚨 **Complete Phase**: Urgent release alert, deployment status -- ⚡ Emphasizes urgency for immediate updates - -**Failure Notifications** (`SLACK_WEBHOOK_FAILURE`): -- 🔍 Direct links to build logs -- 📋 Failed stage identification -- ⚠️ Clear troubleshooting guidance -- 👤 Shows who triggered the failed build - -### GitHub Integration -- ✅ **Detailed Step Summaries**: Process status with clear indicators -- 📥 **Artifact Instructions**: Download links and WordPress installation steps -- 🔗 **Release Links**: GitHub releases, WordPress.org, changelog links -- 📊 **Process Metrics**: Build times, package sizes, quality results - ---- - -## Build Artifacts & Distribution - -### Artifact Structure -``` -popup-maker_1.19.0.zip # Main plugin package -popup-maker_1.19.0.zip.sha256 # Checksum for verification -``` - -### Package Contents -Based on `package.json` files array or default patterns: -- **PHP Files**: `*.php`, `classes/**/*`, `includes/**/*` -- **Assets**: `assets/**/*`, `dist/**/*` (production builds) -- **Templates**: `templates/**/*` -- **Dependencies**: `vendor/**/*` (production only) -- **Documentation**: `readme.txt`, `LICENSE` -- **Excluded**: `build/`, `node_modules/`, `tests/`, development files - -### Distribution Channels -1. **GitHub Artifacts**: 30-day retention for test builds, 90-day for releases -2. **GitHub Releases**: Permanent storage with semantic versioning -3. **WordPress.org SVN**: Official plugin repository (releases only) -4. **Slack Downloads**: Direct links for team collaboration - -### Installation Instructions -**WordPress Admin**: -1. Download ZIP file from artifact/release -2. WordPress Admin → Plugins → Add New → Upload Plugin -3. Select downloaded ZIP file -4. Activate plugin - -**Manual Installation**: -1. Download and extract ZIP file -2. Upload folder to `/wp-content/plugins/` -3. Activate in WordPress admin - ---- - -## Troubleshooting - -### Common Issues - -**Build Failures**: -- Check quality gates (linting, tests) if enabled -- Verify branch exists and is accessible -- Review dependency installation logs -- Check for merge conflicts in release branches - -**Release Failures**: -- Ensure semantic versioning format (e.g., `1.19.0`) -- Verify production environment approval -- Check for existing release tags -- Review git-flow branch requirements - -**Hotfix Issues**: -- Ensure master branch is clean before creating hotfix -- Don't forget to apply actual fixes between create/complete -- Verify hotfix branch exists before completing -- Check WordPress.org deployment credentials - -**Cache Issues**: -- Clear Actions cache if dependencies seem stale -- Check `package-lock.json` and `composer.lock` changes -- NPM cache auto-invalidates on lock file changes - -### Getting Help -1. **Build Logs**: Click workflow run → failed job → expand failed step -2. **Slack Notifications**: Contain direct troubleshooting links -3. **Artifact Downloads**: Check GitHub Actions artifacts section -4. **Manual Builds**: Use local `bin/build-release.js` script for testing - -### Emergency Procedures -**Skip Quality Checks**: Use `Skip Quality Checks: ✅` for emergency releases -**Hotfix Priority**: Hotfixes bypass normal release cycle for critical issues -**Manual Deployment**: WordPress.org deployment can be done separately if automated fails -**Rollback**: Use previous release artifacts if current release has issues - ---- - -## Build Scripts Reference - -### `bin/build-release.js` -**Purpose**: Unified plugin release builder used by all workflows - -**Key Features**: -- Parallel composer and npm builds (40% time savings) -- Uses `package.json` files array for distribution -- Automatic version file updates -- Production dependency optimization -- Comprehensive error handling - -**Manual Usage**: -```bash -# Basic build -node bin/build-release.js - -# Custom build -node bin/build-release.js --zip-name "popup-maker_1.19.0.zip" --keep-build - -# Skip dependencies (faster iteration) -node bin/build-release.js --skip-composer --skip-npm -``` - -### `bin/update-changelog.js` -**Purpose**: Official changelog management used by release workflows - -**Features**: -- Extracts "Unreleased" section from CHANGELOG.md -- Updates both CHANGELOG.md and readme.txt -- Formats content for different contexts -- Preserves changelog history - -**Manual Usage**: -```bash -# Update changelog for version -node bin/update-changelog.js "1.19.0" - -# Verbose output (shows extracted content) -node bin/update-changelog.js "1.19.0" --verbose -``` - ---- - -## Best Practices - -### Development Workflow -1. **Feature Development**: Use Build Test Package on feature branches -2. **Quality Assurance**: Enable linting and tests for pre-release builds -3. **Team Collaboration**: Use Slack notifications for build sharing -4. **Version Naming**: Use descriptive version suffixes for test builds - -### Release Management -1. **Semantic Versioning**: Follow semver strictly (MAJOR.MINOR.PATCH) -2. **Changelog Maintenance**: Keep "Unreleased" section updated -3. **Testing**: Use pre-release versions for beta testing -4. **Documentation**: Update changelog message for significant releases - -### Emergency Response -1. **Hotfix Process**: Always use two-phase hotfix workflow -2. **Testing**: Test hotfix thoroughly even under time pressure -3. **Communication**: Use Slack notifications to alert team immediately -4. **Follow-up**: Ensure hotfix changes are properly integrated - -### Security Considerations -1. **Approval Gates**: Never bypass production environment approval -2. **Credential Management**: Rotate WordPress.org passwords regularly -3. **Access Control**: Limit who can approve production releases -4. **Audit Trail**: All releases have full GitHub Actions logs - ---- - -This documentation covers all GitHub Actions workflows from a practical usage perspective. For technical implementation details, refer to the workflow files in `.github/workflows/`. \ No newline at end of file +- Production workflows use the trusted workflow from `master`, never PR-authored workflow code. +- Publication PRs must originate inside this repository. +- External actions and reusable workflows are pinned to immutable commit SHAs. +- WordPress.org credentials are exposed only after the merged PR is authorized. +- The WordPress changelog receiver uses its dedicated route-scoped token and creates drafts for human review. diff --git a/docs/release-please-integration.md b/docs/release-please-integration.md deleted file mode 100644 index df51b7df8..000000000 --- a/docs/release-please-integration.md +++ /dev/null @@ -1,131 +0,0 @@ -# Release Please Integration (Phase 2) - -## Overview - -Release Please automates semantic versioning and CHANGELOG generation based on conventional commits. It runs on a weekly schedule (Monday 9am UTC) to aggregate all commits into a single release PR. - -## How It Works - -1. **Weekly schedule**: Monday 9am UTC, Release Please analyzes commits since last release -2. **Version calculation**: Automatically calculates next version from commit types: - - `feat:` → minor version bump (1.21.5 → 1.22.0) - - `fix:` → patch version bump (1.21.5 → 1.21.6) - - `BREAKING CHANGE:` → major version bump (1.21.5 → 2.0.0) -3. **Release PR created/updated**: Automated PR with changelog updates -4. **Rollup behavior**: PR accumulates commits until merged (no duplicate version bumps) -5. **Team review**: Review and approve the release PR -6. **Merge triggers release**: Creates tag and updates version files via `update-versions.js` - -## Workflow Triggers - -- **Weekly schedule** (`cron: '0 9 * * 1'`): Primary trigger - Monday 9am UTC -- **Manual dispatch** (`workflow_dispatch`): For testing or emergency patches -- **No push trigger**: Releases are weekly rollups, not per-commit - -## Configuration Files - -### `.release-please-config.json` -Defines how Release Please handles this repository: -- `release-type: simple` - Basic version bumping -- `changelog-path: changelog.txt` - WordPress-style changelog file - -### `.release-please-manifest.json` -Tracks current version: -```json -{ - ".": "1.21.5" -} -``` - -Updated automatically by Release Please when releases are created. - -## Version File Updates - -When Release Please creates a release, the workflow uses the existing `bin/update-versions.js` script (via `npm run version:update`) to update all version references: - -- `popup-maker.php` - Plugin header version -- `bootstrap.php` - Class version constant -- `readme.txt` - Stable tag -- `package.json` / `composer.json` - Package versions -- PHP docblocks - `@since X.X.X` annotations - -This approach leverages the battle-tested version update script already used in manual releases. - -## Workflow File - -`.github/workflows/release-please.yml`: -1. Runs on weekly schedule or manual dispatch -2. Analyzes commits since last release -3. Creates/updates release PR with calculated version -4. When PR is merged: runs `npm run version:update` to update all files -5. Outputs release metadata for Phase 3+ (Slack notifications, deployments) - -## Integration Points - -### Phase 1 Foundation -- **Commitlint** validates commit format (Phase 1) -- **Release Please** processes valid commits (Phase 2) - -### Phase 3 Slack Approval (Future) -- Release Please PR triggers Slack notification -- Team approves via Slack button -- Approval merges PR and triggers release - -### Phase 4 Testing (Future) -- Merged release PR triggers InstaWP test instance -- Automated testing validates release - -### Phase 5 Deployment (Future) -- Successful tests trigger WordPress.org and EDD deployment -- Automated release to production - -## Weekly Release Schedule - -**Monday 9am UTC**: -1. Release Please creates/updates PR with week's accumulated changes -2. Slack notification sent to team (Phase 3) -3. Team reviews changelog and approves -4. Merge triggers version updates, testing, and deployment - -## Emergency Patches - -For critical hotfixes that can't wait for weekly schedule: - -```bash -npm run prepare-release:patch -- --auto -``` - -This bypasses Release Please and uses the manual release process directly. - -## Version Bumping Rules - -| Commit Type | Version Change | Example | -|-------------|----------------|---------| -| `feat:` | Minor bump | 1.21.5 → 1.22.0 | -| `fix:` | Patch bump | 1.21.5 → 1.21.6 | -| `perf:` | Patch bump | 1.21.5 → 1.21.6 | -| `BREAKING CHANGE:` | Major bump | 1.21.5 → 2.0.0 | -| `chore:`, `docs:`, `style:` | No bump | No release | - -## Testing Phase 2 - -To test Release Please integration: - -1. **Trigger manually**: Go to Actions → Release Please → Run workflow - -2. **Watch for PR**: Release Please should create a PR within 1-2 minutes - -3. **Review PR**: Check that: - - Version calculated correctly - - Changelog updated with commit messages - -4. **Merge PR**: Merging will: - - Tag the release - - Run `npm run version:update` to update all files - - Trigger Phase 3+ workflows (when implemented) - -## Next Steps - -- **Phase 3**: Slack approval workflow with approval buttons -- **Phase 4**: InstaWP testing integration -- **Phase 5**: Automated deployment to WordPress.org and EDD diff --git a/readme.md b/readme.md index 01566a1a2..75241f5a0 100644 --- a/readme.md +++ b/readme.md @@ -37,35 +37,38 @@ Then, move on to our [Setting up your local environment](https://github.com/Popu ### Release Preparation -The `bin/prepare-release.js` script automates the complete release workflow: +The `bin/prepare-release.js` script prepares a reviewed release PR: ```bash # Patch release (1.21.4 → 1.21.5) -node bin/prepare-release.js +pnpm run prepare-release start # Minor release (1.21.4 → 1.22.0) -node bin/prepare-release.js --minor +pnpm run prepare-release start -- --minor # Specific version -node bin/prepare-release.js 2.1.0 +pnpm run prepare-release start -- 2.1.0 # Test without changes -node bin/prepare-release.js --dry-run +pnpm run prepare-release start -- --dry-run ``` **Features:** -- 🔄 Automatic version increments or specific versions -- 🌿 Git flow integration (release branches and tags) -- 📝 Updates versions in all files and changelog -- 🔨 Builds release assets -- 🚀 Interactive push options + +- 🔄 Automatic version increments or specific versions +- 🌿 Creates a `release/X.Y.Z` branch for review +- 📝 Updates versions in all files and changelog +- 🔨 Builds release assets +- 🚀 Opens the release PR; approval and merge perform publication See `bin/README.md` for complete documentation. ## Deployment -This is a WordPress plugin that is hosted on the WordPress.org SVN repo. -There is not currently any automated deployment. Instead, once a release is published on GitHub, that release is manually uploaded to the SVN. +This plugin is hosted on WordPress.org SVN. An approved and merged `release/X.Y.Z` +PR publishes the canonical GitHub Actions artifact to GitHub Releases, EDD, +Google Drive, and WordPress.org. Approved readme/assets-only PRs use a separate +narrow SVN sync. Direct pushes to `master` do not publish. ## Contributing diff --git a/tests/unit/bin/validate-release-version.test.js b/tests/unit/bin/validate-release-version.test.js new file mode 100644 index 000000000..2e80e14eb --- /dev/null +++ b/tests/unit/bin/validate-release-version.test.js @@ -0,0 +1,77 @@ +const fs = require( 'fs' ); +const os = require( 'os' ); +const path = require( 'path' ); + +const { + compareVersions, + validateReleaseVersion, +} = require( '../../../bin/validate-release-version' ); + +describe( 'release version validation', () => { + let projectRoot; + + beforeEach( () => { + projectRoot = fs.mkdtempSync( + path.join( os.tmpdir(), 'popup-maker-release-version-' ) + ); + fs.writeFileSync( + path.join( projectRoot, 'package.json' ), + JSON.stringify( { version: '1.25.0' } ) + ); + fs.writeFileSync( + path.join( projectRoot, 'popup-maker.php' ), + " * Version: 1.25.0\n'version' => '1.25.0',\n" + ); + fs.writeFileSync( + path.join( projectRoot, 'readme.txt' ), + 'Stable tag: 1.25.0\n\n= 1.25.0 - 2026-09-01 =\n' + ); + fs.writeFileSync( + path.join( projectRoot, 'CHANGELOG.md' ), + '## v1.25.0 - 2026-09-01\n' + ); + } ); + + afterEach( () => { + fs.rmSync( projectRoot, { recursive: true, force: true } ); + } ); + + test( 'accepts one consistent version newer than the base release', () => { + expect( + validateReleaseVersion( { + projectRoot, + version: '1.25.0', + previousVersion: '1.24.0', + } ) + ).toMatchObject( { + 'package.json': '1.25.0', + 'readme.txt stable tag': '1.25.0', + } ); + } ); + + test( 'rejects mismatched release files', () => { + fs.writeFileSync( + path.join( projectRoot, 'readme.txt' ), + 'Stable tag: 1.24.0\n\n= 1.25.0 - 2026-09-01 =\n' + ); + + expect( () => + validateReleaseVersion( { projectRoot, version: '1.25.0' } ) + ).toThrow( 'readme.txt stable tag=1.24.0' ); + } ); + + test( 'rejects a release that does not advance the base version', () => { + expect( () => + validateReleaseVersion( { + projectRoot, + version: '1.25.0', + previousVersion: '1.25.0', + } ) + ).toThrow( 'must be newer' ); + } ); + + test( 'compares semantic version parts numerically', () => { + expect( compareVersions( '1.25.0', '1.24.9' ) ).toBeGreaterThan( 0 ); + expect( compareVersions( '2.0.0', '1.99.99' ) ).toBeGreaterThan( 0 ); + } ); +} ); From cee7af2b777b20cf69a3ac3b9781b6949400bfe7 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 31 Aug 2026 22:45:53 -0400 Subject: [PATCH 2/7] ci: run pnpm jobs on supported Node --- .github/workflows/build.yml | 4 ++-- .github/workflows/ci.yml | 6 +++--- .github/workflows/tests.yml | 4 ++-- .github/workflows/update-google-fonts.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03f17ed56..9bdf6fbab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -127,7 +127,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install dependencies @@ -208,7 +208,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Capture build information diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80b3fafd0..2250db3d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,7 +233,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install dependencies @@ -301,7 +301,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install dependencies @@ -460,7 +460,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 788ec1069..a76326d96 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -302,7 +302,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "pnpm" - name: Install pnpm dependencies @@ -343,7 +343,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "pnpm" - name: Setup PHP diff --git a/.github/workflows/update-google-fonts.yml b/.github/workflows/update-google-fonts.yml index 2ed109efc..d14f8a7ae 100644 --- a/.github/workflows/update-google-fonts.yml +++ b/.github/workflows/update-google-fonts.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install dependencies From e451705aaf9e0dbc2c6f26ebe3ab642720a2716a Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 31 Aug 2026 22:52:17 -0400 Subject: [PATCH 3/7] ci: avoid incompatible Composer job tokens --- .github/workflows/build.yml | 2 ++ .github/workflows/ci.yml | 12 +++++++++--- .github/workflows/tests.yml | 12 +++++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9bdf6fbab..beeb3aa96 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -132,6 +132,7 @@ jobs: - name: Install dependencies run: | + composer config --global --unset github-oauth.github.com || true composer install --no-interaction --no-progress pnpm install --frozen-lockfile @@ -302,6 +303,7 @@ jobs: - name: Install production dependencies run: | + composer config --global --unset github-oauth.github.com || true composer install --no-dev --optimize-autoloader --no-interaction --no-progress pnpm install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2250db3d8..11561730e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,9 @@ jobs: - name: Install composer dependencies if: steps.changed-files.outputs.any_changed == 'true' - run: composer install --no-interaction --optimize-autoloader --ignore-platform-reqs + run: | + composer config --global --unset github-oauth.github.com || true + composer install --no-interaction --optimize-autoloader --ignore-platform-reqs - name: Run PHPCS on changed files if: steps.changed-files.outputs.any_changed == 'true' @@ -490,7 +492,9 @@ jobs: tools: composer - name: Install composer dependencies - run: composer install --no-interaction --ignore-platform-reqs + run: | + composer config --global --unset github-oauth.github.com || true + composer install --no-interaction --ignore-platform-reqs - name: Run composer audit continue-on-error: true @@ -528,7 +532,9 @@ jobs: coverage: xdebug - name: Install composer dependencies - run: composer install --prefer-dist --no-progress --ignore-platform-reqs + run: | + composer config --global --unset github-oauth.github.com || true + composer install --prefer-dist --no-progress --ignore-platform-reqs - name: Install WordPress test environment run: bash bin/install-wp-tests.sh wordpress_test root 'password' mysql diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a76326d96..f60beba31 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -201,7 +201,9 @@ jobs: run: sudo apt-get update && sudo apt-get install -y subversion - name: Install Composer dependencies - run: composer install --prefer-dist --no-progress + run: | + composer config --global --unset github-oauth.github.com || true + composer install --prefer-dist --no-progress - name: Install WordPress test suite run: | @@ -268,7 +270,9 @@ jobs: run: sudo apt-get update && sudo apt-get install -y subversion - name: Install Composer dependencies - run: composer install --prefer-dist --no-progress + run: | + composer config --global --unset github-oauth.github.com || true + composer install --prefer-dist --no-progress - name: Install WordPress test suite run: | @@ -365,7 +369,9 @@ jobs: restore-keys: ${{ runner.os }}-composer-8.2- - name: Install Composer dependencies - run: composer install --prefer-dist --no-progress + run: | + composer config --global --unset github-oauth.github.com || true + composer install --prefer-dist --no-progress - name: Install pnpm dependencies run: pnpm install --frozen-lockfile From 675aa3b4b50a33498b443e5909194539e5049e5e Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 31 Aug 2026 22:58:27 -0400 Subject: [PATCH 4/7] fix: harden approved publication retries --- .github/workflows/deploy-readme-assets.yml | 18 ++++++++++-- .github/workflows/pr-target-check.yml | 4 ++- .github/workflows/publication-gate.yml | 2 +- .github/workflows/release.yml | 32 +++++++++++++++++++--- .github/workflows/tests.yml | 1 + bin/README.md | 10 +++---- readme.md | 4 +-- 7 files changed, 55 insertions(+), 16 deletions(-) diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 9abe669bf..0df5d7d85 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -16,7 +16,7 @@ permissions: pull-requests: read concurrency: - group: wordpress-org-readme-assets-${{ github.event.pull_request.number || inputs.pull_request_number }} + group: popup-maker-publication cancel-in-progress: false jobs: @@ -104,8 +104,11 @@ jobs: pull_number: number, per_page: 100, }); - const allowed = files.length > 0 && files.every(({ filename }) => - filename === 'readme.txt' || filename.startsWith('.wordpress-org/') + const isAllowedPath = (filename) => + filename === 'readme.txt' || filename.startsWith('.wordpress-org/'); + const allowed = files.length > 0 && files.every((file) => + isAllowedPath(file.filename) && + (file.status !== 'renamed' || isAllowedPath(file.previous_filename || '')) ); if (!allowed) { core.info('PR is not readme/assets-only; the narrow SVN sync will not run.'); @@ -126,6 +129,15 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.authorize.outputs.merge_sha }} + fetch-depth: 0 + + - name: Refuse stale readme or assets + run: | + git fetch --no-tags origin master + if ! git diff --quiet HEAD FETCH_HEAD -- readme.txt .wordpress-org; then + echo 'A newer readme or asset change exists on master. Refusing to publish stale files.' + exit 1 + fi - name: Sync readme and assets uses: 10up/action-wordpress-plugin-asset-update@2480306f6f693672726d08b5917ea114cb2825f7 # stable diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 8389692dc..32139a6fc 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -11,7 +11,9 @@ jobs: runs-on: ubuntu-latest if: >- github.event.pull_request.author_association != 'OWNER' && - github.event.pull_request.author_association != 'MEMBER' + github.event.pull_request.author_association != 'MEMBER' && + !(github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/')) steps: - name: Retarget to develop env: diff --git a/.github/workflows/publication-gate.yml b/.github/workflows/publication-gate.yml index 7fe66755d..b72958bd2 100644 --- a/.github/workflows/publication-gate.yml +++ b/.github/workflows/publication-gate.yml @@ -32,7 +32,7 @@ jobs: HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} REPOSITORY: ${{ github.repository }} run: | - CHANGED_FILES=$(git diff --name-only "${BASE_SHA}" "HEAD") + CHANGED_FILES=$(git diff --name-only --no-renames "${BASE_SHA}" "HEAD") echo "${CHANGED_FILES}" if [[ "${HEAD_BRANCH}" =~ ^release/([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 010057351..73f31a424 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ permissions: pull-requests: read concurrency: - group: popup-maker-release-${{ github.event.pull_request.number || inputs.pull_request_number }} + group: popup-maker-publication cancel-in-progress: false env: @@ -112,6 +112,26 @@ jobs: } const version = branchMatch[1]; + const { data: masterPackageFile } = await github.rest.repos.getContent({ + owner, + repo, + path: 'package.json', + ref: 'master', + }); + if (Array.isArray(masterPackageFile) || masterPackageFile.type !== 'file') { + core.setFailed('Could not read package.json from master.'); + return; + } + const masterPackage = JSON.parse( + Buffer.from(masterPackageFile.content, 'base64').toString('utf8') + ); + if (masterPackage.version !== version) { + core.setFailed( + `Release ${version} is stale; master currently declares ${masterPackage.version}.` + ); + return; + } + let existingTagCommit = ''; try { const { data: ref } = await github.rest.git.getRef({ @@ -396,13 +416,13 @@ jobs: needs: [authorize, github-release] runs-on: ubuntu-latest permissions: + actions: write contents: read pull-requests: write steps: - name: Open or reuse master to develop PR id: back_sync - continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} @@ -414,12 +434,16 @@ jobs: exit 0 fi - gh pr create \ + PR_URL=$(gh pr create \ --repo "${REPO}" \ --base develop \ --head master \ --title "Back-sync release ${VERSION} to develop" \ - --body "Carries the approved ${VERSION} release metadata and release merge back to develop." + --body "Carries the approved ${VERSION} release metadata and release merge back to develop.") + + echo "Back-sync PR opened: ${PR_URL}" + gh workflow run ci.yml --repo "${REPO}" --ref master + gh workflow run tests.yml --repo "${REPO}" --ref master slack-success: name: Slack success notification diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f60beba31..a3489ef7a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: branches: [main, master, develop] pull_request: branches: [main, master, develop] + workflow_dispatch: # Cancel in-progress runs for the same branch concurrency: diff --git a/bin/README.md b/bin/README.md index 59856492d..976b2d91d 100644 --- a/bin/README.md +++ b/bin/README.md @@ -26,19 +26,19 @@ The **prepare-release.js** script prepares a release branch, package, and PR. It pnpm run prepare-release start # Minor release (1.21.4 → 1.22.0) -pnpm run prepare-release start -- --minor +pnpm run prepare-release start --minor # Major release (1.21.4 → 2.0.0) -pnpm run prepare-release start -- --major +pnpm run prepare-release start --major # Specific version -pnpm run prepare-release start -- 2.1.0 +pnpm run prepare-release start 2.1.0 # Test without changes -pnpm run prepare-release start -- --dry-run +pnpm run prepare-release start --dry-run # See all options -pnpm run prepare-release -- --help +pnpm run prepare-release --help ``` ### What It Does diff --git a/readme.md b/readme.md index 75241f5a0..c6fc73c84 100644 --- a/readme.md +++ b/readme.md @@ -44,13 +44,13 @@ The `bin/prepare-release.js` script prepares a reviewed release PR: pnpm run prepare-release start # Minor release (1.21.4 → 1.22.0) -pnpm run prepare-release start -- --minor +pnpm run prepare-release start --minor # Specific version pnpm run prepare-release start -- 2.1.0 # Test without changes -pnpm run prepare-release start -- --dry-run +pnpm run prepare-release start --dry-run ``` **Features:** From f3fe037c0ad1acc17a5db438ec1a759aee1999fc Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Tue, 1 Sep 2026 04:53:06 -0400 Subject: [PATCH 5/7] fix: address final release flow review --- .github/workflows/publication-gate.yml | 5 +++-- .github/workflows/release.yml | 12 ++++++++++ bin/update-versions.js | 31 +++++++++++++++++--------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publication-gate.yml b/.github/workflows/publication-gate.yml index b72958bd2..58d8f91b8 100644 --- a/.github/workflows/publication-gate.yml +++ b/.github/workflows/publication-gate.yml @@ -3,7 +3,7 @@ name: Publication Gate on: pull_request: branches: [master] - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, edited] permissions: contents: read @@ -21,8 +21,9 @@ jobs: - name: Checkout proposed merge uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: refs/pull/${{ github.event.pull_request.number }}/merge fetch-depth: 0 + persist-credentials: false - name: Classify publication id: publication diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73f31a424..14f77b2ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,18 @@ jobs: } const version = branchMatch[1]; + const { data: masterBranch } = await github.rest.repos.getBranch({ + owner, + repo, + branch: 'master', + }); + if (masterBranch.commit.sha !== pull.merge_commit_sha) { + core.setFailed( + `Release ${version} is stale; master has changed since PR #${number} merged.` + ); + return; + } + const { data: masterPackageFile } = await github.rest.repos.getContent({ owner, repo, diff --git a/bin/update-versions.js b/bin/update-versions.js index 234c493fa..aa8177787 100644 --- a/bin/update-versions.js +++ b/bin/update-versions.js @@ -1,3 +1,5 @@ +/* eslint-disable no-console */ + /** * Replaces version numbers in files. * @@ -31,9 +33,9 @@ const dryRun = argv[ 'dry-run' ]; let replaceType = 'all'; -if ( argv[ 'plugin' ] ) { +if ( argv.plugin ) { replaceType = 'plugin'; -} else if ( argv[ 'docblock' ] ) { +} else if ( argv.docblock ) { replaceType = 'docblock'; } @@ -109,12 +111,12 @@ const commentPatterns = [ /** * Update version in specified files with the given patterns. * - * @param {string} filePath - Path to the file. - * @param {string} newVersion - The new version number. - * @param {boolean} dryRun - Indicate if this is a dry run. - * @param {Array} patterns - Array of regex patterns to match and replace. + * @param {string} filePath - Path to the file. + * @param {string} newVersion - The new version number. + * @param {boolean} isDryRun - Indicate if this is a dry run. + * @param {Array} patterns - Array of regex patterns to match and replace. */ -function updateVersionInFile( filePath, newVersion, dryRun, patterns ) { +function updateVersionInFile( filePath, newVersion, isDryRun, patterns ) { if ( fs.existsSync( filePath ) ) { const contents = fs.readFileSync( filePath, 'utf8' ); let newContents = contents; @@ -127,7 +129,7 @@ function updateVersionInFile( filePath, newVersion, dryRun, patterns ) { } ); if ( newContents !== contents ) { - if ( dryRun ) { + if ( isDryRun ) { console.log( `${ filePath }:` ); console.log( newContents ); } else { @@ -140,11 +142,16 @@ function updateVersionInFile( filePath, newVersion, dryRun, patterns ) { } if ( replaceType === 'all' || replaceType === 'plugin' ) { - const pluginSlug = path.basename( process.cwd() ); - const pluginFile = process.cwd() + '/' + pluginSlug + '.php'; + const packageJsonFile = path.join( process.cwd(), 'package.json' ); + const packageName = fs.existsSync( packageJsonFile ) + ? JSON.parse( fs.readFileSync( packageJsonFile, 'utf8' ) ).name + : ''; + const pluginSlug = packageName + ? path.basename( packageName ) + : path.basename( process.cwd() ).toLowerCase(); + const pluginFile = path.join( process.cwd(), `${ pluginSlug }.php` ); const boostrapFile = process.cwd() + '/bootstrap.php'; const readmeFile = process.cwd() + '/readme.txt'; - const packageJsonFile = process.cwd() + '/' + 'package.json'; const composerJsonFile = process.cwd() + '/' + 'composer.json'; if ( fs.existsSync( pluginFile ) ) { @@ -196,3 +203,5 @@ if ( } } ); } + +/* eslint-enable no-console */ From 447975dc154aeaa7315ae8384da26b6aa247112c Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Tue, 1 Sep 2026 05:15:57 -0400 Subject: [PATCH 6/7] fix: address CodeRabbit release review --- .github/workflows/deploy-readme-assets.yml | 10 ++++ .github/workflows/publication-gate.yml | 13 ++++- .github/workflows/release.yml | 27 ++++++++- bin/validate-release-version.js | 56 ++++++++++++++++--- docs/github-actions.md | 6 +- readme.md | 9 +-- .../unit/bin/validate-release-version.test.js | 22 ++++++++ 7 files changed, 124 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 0df5d7d85..0d9629a31 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -130,6 +130,7 @@ jobs: with: ref: ${{ needs.authorize.outputs.merge_sha }} fetch-depth: 0 + persist-credentials: false - name: Refuse stale readme or assets run: | @@ -139,6 +140,15 @@ jobs: exit 1 fi + - name: Reject symlinked publication files + run: | + if [ -L readme.txt ] || + [ -L .wordpress-org ] || + { [ -d .wordpress-org ] && find .wordpress-org -type l -print -quit | grep -q .; }; then + echo 'Symlinks are not allowed in publication files.' + exit 1 + fi + - name: Sync readme and assets uses: 10up/action-wordpress-plugin-asset-update@2480306f6f693672726d08b5917ea114cb2825f7 # stable env: diff --git a/.github/workflows/publication-gate.yml b/.github/workflows/publication-gate.yml index 58d8f91b8..f9220c9f9 100644 --- a/.github/workflows/publication-gate.yml +++ b/.github/workflows/publication-gate.yml @@ -25,6 +25,15 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Reject symlinked publication files + run: | + if [ -L readme.txt ] || + [ -L .wordpress-org ] || + { [ -d .wordpress-org ] && find .wordpress-org -type l -print -quit | grep -q .; }; then + echo 'Symlinks are not allowed in publication files.' + exit 1 + fi + - name: Classify publication id: publication env: @@ -44,7 +53,9 @@ jobs: echo "type=release" >> "${GITHUB_OUTPUT}" echo "version=${BASH_REMATCH[1]}" >> "${GITHUB_OUTPUT}" - elif [ -n "${CHANGED_FILES}" ] && ! echo "${CHANGED_FILES}" | grep -Ev '^(readme\.txt|\.wordpress-org/)' >/dev/null; then + elif [ "${HEAD_REPOSITORY}" = "${REPOSITORY}" ] && + [ -n "${CHANGED_FILES}" ] && + ! echo "${CHANGED_FILES}" | grep -Ev '^(readme\.txt|\.wordpress-org/)' >/dev/null; then echo "type=readme-assets" >> "${GITHUB_OUTPUT}" else echo "type=none" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14f77b2ea..3563ba285 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,6 +187,16 @@ jobs: with: ref: ${{ needs.authorize.outputs.merge_sha }} fetch-depth: 0 + persist-credentials: false + + - name: Reject symlinked publication files + run: | + if [ -L readme.txt ] || + [ -L .wordpress-org ] || + { [ -d .wordpress-org ] && find .wordpress-org -type l -print -quit | grep -q .; }; then + echo 'Symlinks are not allowed in publication files.' + exit 1 + fi - name: Validate release version env: @@ -312,6 +322,7 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.authorize.outputs.merge_sha }} + persist-credentials: false - name: Download canonical release package uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 @@ -342,6 +353,16 @@ jobs: missing_direct_file_access_protection ignore-warnings: true + - name: Reject symlinked deployment files + run: | + if [ -L .wordpress-org ] || + { [ -d .wordpress-org ] && find .wordpress-org -type l -print -quit | grep -q .; } || + [ -L "${BUILD_DIR}" ] || + { [ -d "${BUILD_DIR}" ] && find "${BUILD_DIR}" -type l -print -quit | grep -q .; }; then + echo 'Symlinks are not allowed in deployment files.' + exit 1 + fi + - name: Deploy to WordPress.org uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # stable env: @@ -476,7 +497,8 @@ jobs: needs.wordpress-org.result == 'success' && needs.edd-webhook.result == 'success' && needs.google-drive-upload.result == 'success' && - needs.changelog.result == 'success' + needs.changelog.result == 'success' && + needs.back-sync.result == 'success' runs-on: ubuntu-latest steps: - name: Send success notification @@ -520,7 +542,8 @@ jobs: needs.wordpress-org.result == 'failure' || needs.edd-webhook.result == 'failure' || needs.google-drive-upload.result == 'failure' || - needs.changelog.result == 'failure') + needs.changelog.result == 'failure' || + needs.back-sync.result == 'failure') runs-on: ubuntu-latest steps: - name: Send failure notification diff --git a/bin/validate-release-version.js b/bin/validate-release-version.js index d311b16d5..5e9eb2c4f 100644 --- a/bin/validate-release-version.js +++ b/bin/validate-release-version.js @@ -31,6 +31,44 @@ function compareVersions( left, right ) { return 0; } +/** + * Check whether a heading contains a real YYYY-MM-DD calendar date. + * + * @param {string} contents File contents. + * @param {RegExp} pattern Heading pattern with year, month, and day captures. + * @return {boolean} Whether the heading contains a valid date. + */ +function hasValidDatedHeading( contents, pattern ) { + const match = contents.match( pattern ); + + if ( ! match ) { + return false; + } + + const year = Number( match[ 1 ] ); + const month = Number( match[ 2 ] ); + const day = Number( match[ 3 ] ); + const leapYear = 0 === year % 400 || ( 0 === year % 4 && 0 !== year % 100 ); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + + return ( + month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth[ month - 1 ] + ); +} + function validateReleaseVersion( { projectRoot = process.cwd(), version, @@ -90,22 +128,22 @@ function validateReleaseVersion( { } const escapedVersion = version.replace( /\./g, '\\.' ); - const datedHeading = `\\d{4}-\\d{2}-\\d{2}`; + const datedHeading = `(\\d{4})-(\\d{2})-(\\d{2})`; if ( - ! new RegExp( - `^## v${ escapedVersion } - ${ datedHeading }$`, - 'm' - ).test( changelogContents ) + ! hasValidDatedHeading( + changelogContents, + new RegExp( `^## v${ escapedVersion } - ${ datedHeading }$`, 'm' ) + ) ) { throw new Error( `CHANGELOG.md has no dated v${ version } entry.` ); } if ( - ! new RegExp( - `^= ${ escapedVersion } - ${ datedHeading } =$`, - 'm' - ).test( readmeContents ) + ! hasValidDatedHeading( + readmeContents, + new RegExp( `^= ${ escapedVersion } - ${ datedHeading } =$`, 'm' ) + ) ) { throw new Error( `readme.txt has no dated ${ version } changelog entry.` diff --git a/docs/github-actions.md b/docs/github-actions.md index 90c53a7e2..fb83c364e 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -8,7 +8,7 @@ Popup Maker uses reviewed pull requests as the only production publication gate. 2. Update the plugin versions and dated changelogs. 3. Open the PR against `master` with `pnpm run prepare-release finish`. 4. Review the candidate ZIP and required checks in the PR. -5. Approve and merge the PR. +5. Authorize and merge the PR. Authorization may come from a current maintainer approval or from an authorized maintainer performing the merge. The merged PR is re-authorized before any external write. It must: @@ -31,11 +31,11 @@ After those checks pass, `release.yml` builds one canonical ZIP and uses that sa It also attempts to open a `master` to `develop` back-sync PR. A failed downstream step is visible and can be retried by manually running the workflow with the original merged PR number. -Direct tags, direct pushes to `master`, and unapproved PRs do not publish a plugin release. +Direct tags and direct pushes to `master` do not publish a plugin release. A merged release PR publishes only when authorized by a current maintainer approval or an authorized maintainer merge. ## WordPress.org readme and assets -A PR containing only `readme.txt` and/or files below `.wordpress-org/` may be opened against `master`. After it is approved and merged, `deploy-readme-assets.yml` re-checks the approval and exact file list, then syncs only those files to WordPress.org. +A same-repository PR containing only `readme.txt` and/or files below `.wordpress-org/` may be opened against `master`. After a current maintainer approves it or an authorized maintainer merges it, `deploy-readme-assets.yml` re-checks the authorization and exact file list, then syncs only those files to WordPress.org. A mixed code/readme PR never enters this narrow path. Release PRs deploy their readme and assets with the full canonical package. diff --git a/readme.md b/readme.md index c6fc73c84..42c0a769b 100644 --- a/readme.md +++ b/readme.md @@ -59,15 +59,16 @@ pnpm run prepare-release start --dry-run - 🌿 Creates a `release/X.Y.Z` branch for review - 📝 Updates versions in all files and changelog - 🔨 Builds release assets -- 🚀 Opens the release PR; approval and merge perform publication +- 🚀 Opens the release PR; maintainer authorization and merge perform publication See `bin/README.md` for complete documentation. ## Deployment -This plugin is hosted on WordPress.org SVN. An approved and merged `release/X.Y.Z` -PR publishes the canonical GitHub Actions artifact to GitHub Releases, EDD, -Google Drive, and WordPress.org. Approved readme/assets-only PRs use a separate +This plugin is hosted on WordPress.org SVN. A merged `release/X.Y.Z` PR authorized +by a current maintainer approval or an authorized maintainer merge publishes the +canonical GitHub Actions artifact to GitHub Releases, EDD, Google Drive, and +WordPress.org. Authorized same-repository readme/assets-only PRs use a separate narrow SVN sync. Direct pushes to `master` do not publish. ## Contributing diff --git a/tests/unit/bin/validate-release-version.test.js b/tests/unit/bin/validate-release-version.test.js index 2e80e14eb..f93445b04 100644 --- a/tests/unit/bin/validate-release-version.test.js +++ b/tests/unit/bin/validate-release-version.test.js @@ -70,6 +70,28 @@ describe( 'release version validation', () => { ).toThrow( 'must be newer' ); } ); + test( 'rejects an impossible CHANGELOG.md date', () => { + fs.writeFileSync( + path.join( projectRoot, 'CHANGELOG.md' ), + '## v1.25.0 - 2026-99-99\n' + ); + + expect( () => + validateReleaseVersion( { projectRoot, version: '1.25.0' } ) + ).toThrow( 'CHANGELOG.md has no dated v1.25.0 entry' ); + } ); + + test( 'rejects an impossible readme.txt date', () => { + fs.writeFileSync( + path.join( projectRoot, 'readme.txt' ), + 'Stable tag: 1.25.0\n\n= 1.25.0 - 2026-02-30 =\n' + ); + + expect( () => + validateReleaseVersion( { projectRoot, version: '1.25.0' } ) + ).toThrow( 'readme.txt has no dated 1.25.0 changelog entry' ); + } ); + test( 'compares semantic version parts numerically', () => { expect( compareVersions( '1.25.0', '1.24.9' ) ).toBeGreaterThan( 0 ); expect( compareVersions( '2.0.0', '1.99.99' ) ).toBeGreaterThan( 0 ); From 22aad86d822ddbb87a8e771a4a1f814fd19757fd Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Tue, 1 Sep 2026 06:45:05 -0400 Subject: [PATCH 7/7] fix: validate release back-sync merges --- .github/workflows/deploy-readme-assets.yml | 10 ++- .github/workflows/release.yml | 74 +++++++++++++++++----- .github/workflows/tests.yml | 41 +++++++----- docs/github-actions.md | 2 +- 4 files changed, 94 insertions(+), 33 deletions(-) diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 0d9629a31..0a7affd79 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -106,7 +106,11 @@ jobs: }); const isAllowedPath = (filename) => filename === 'readme.txt' || filename.startsWith('.wordpress-org/'); - const allowed = files.length > 0 && files.every((file) => + const removesReadme = files.some((file) => + (file.filename === 'readme.txt' && file.status === 'removed') || + (file.status === 'renamed' && file.previous_filename === 'readme.txt') + ); + const allowed = files.length > 0 && !removesReadme && files.every((file) => isAllowedPath(file.filename) && (file.status !== 'renamed' || isAllowedPath(file.previous_filename || '')) ); @@ -142,6 +146,10 @@ jobs: - name: Reject symlinked publication files run: | + if [ ! -f readme.txt ]; then + echo 'readme.txt is required for a WordPress.org update.' + exit 1 + fi if [ -L readme.txt ] || [ -L .wordpress-org ] || { [ -d .wordpress-org ] && find .wordpress-org -type l -print -quit | grep -q .; }; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3563ba285..b20e387ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -445,12 +445,12 @@ jobs: WORDPRESS_APPLICATION_PASSWORD: ${{ secrets.WORDPRESS_APPLICATION_PASSWORD }} back-sync: - name: Open master back-sync PR + name: Open and validate master back-sync PR needs: [authorize, github-release] runs-on: ubuntu-latest permissions: actions: write - contents: read + contents: write pull-requests: write steps: @@ -461,22 +461,66 @@ jobs: REPO: ${{ github.repository }} VERSION: ${{ needs.authorize.outputs.version }} run: | - EXISTING=$(gh pr list --repo "${REPO}" --base develop --head master --state open --json url --jq '.[0].url // ""') - if [ -n "${EXISTING}" ]; then - echo "Back-sync PR already open: ${EXISTING}" - exit 0 + PR_NUMBER=$(gh pr list --repo "${REPO}" --base develop --head master --state open --json number --jq '.[0].number // ""') + if [ -n "${PR_NUMBER}" ]; then + PR_URL=$(gh pr view "${PR_NUMBER}" --repo "${REPO}" --json url --jq '.url') + echo "Back-sync PR already open: ${PR_URL}" + else + PR_URL=$(gh pr create \ + --repo "${REPO}" \ + --base develop \ + --head master \ + --title "Back-sync release ${VERSION} to develop" \ + --body "Carries the approved ${VERSION} release metadata and release merge back to develop.") + PR_NUMBER=${PR_URL##*/} + echo "Back-sync PR opened: ${PR_URL}" + fi + + MERGE_SHA="" + for _ in {1..30}; do + MERGE_SHA=$(gh api "repos/${REPO}/git/ref/pull/${PR_NUMBER}/merge" --jq '.object.sha' 2>/dev/null || true) + [ -n "${MERGE_SHA}" ] && break + sleep 2 + done + if [ -z "${MERGE_SHA}" ]; then + echo "Back-sync PR #${PR_NUMBER} has no mergeable test commit." + exit 1 fi - PR_URL=$(gh pr create \ + VALIDATION_BRANCH="back-sync-validation/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cleanup() { + gh api --method DELETE "repos/${REPO}/git/refs/heads/${VALIDATION_BRANCH}" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + gh api --method POST "repos/${REPO}/git/refs" \ + -f ref="refs/heads/${VALIDATION_BRANCH}" \ + -f sha="${MERGE_SHA}" >/dev/null + gh workflow run tests.yml \ --repo "${REPO}" \ - --base develop \ - --head master \ - --title "Back-sync release ${VERSION} to develop" \ - --body "Carries the approved ${VERSION} release metadata and release merge back to develop.") - - echo "Back-sync PR opened: ${PR_URL}" - gh workflow run ci.yml --repo "${REPO}" --ref master - gh workflow run tests.yml --repo "${REPO}" --ref master + --ref "${VALIDATION_BRANCH}" \ + -f back_sync=true + + TEST_RUN_ID="" + for _ in {1..30}; do + TEST_RUN_ID=$(gh run list \ + --repo "${REPO}" \ + --workflow tests.yml \ + --branch "${VALIDATION_BRANCH}" \ + --event workflow_dispatch \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // ""') + [ -n "${TEST_RUN_ID}" ] && break + sleep 2 + done + if [ -z "${TEST_RUN_ID}" ]; then + echo 'The back-sync validation workflow did not start.' + exit 1 + fi + + echo "Waiting for back-sync validation run ${TEST_RUN_ID}." + gh run watch "${TEST_RUN_ID}" --repo "${REPO}" --exit-status slack-success: name: Slack success notification diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3489ef7a..4e5b2449c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,12 @@ on: pull_request: branches: [main, master, develop] workflow_dispatch: + inputs: + back_sync: + description: Run the full test suite for a release back-sync merge. + required: false + default: false + type: boolean # Cancel in-progress runs for the same branch concurrency: @@ -151,11 +157,12 @@ jobs: runs-on: ubuntu-latest needs: detect-changes if: | - needs.detect-changes.outputs.has_php_unit_tests == 'true' && - (needs.detect-changes.outputs.php == 'true' || - needs.detect-changes.outputs.php_tests == 'true' || - needs.detect-changes.outputs.php_deps == 'true' || - needs.detect-changes.outputs.ci == 'true') + inputs.back_sync == true || + (needs.detect-changes.outputs.has_php_unit_tests == 'true' && + (needs.detect-changes.outputs.php == 'true' || + needs.detect-changes.outputs.php_tests == 'true' || + needs.detect-changes.outputs.php_deps == 'true' || + needs.detect-changes.outputs.ci == 'true')) services: mysql: @@ -223,12 +230,13 @@ jobs: runs-on: ubuntu-latest needs: detect-changes if: | - needs.detect-changes.outputs.has_php_integration_tests == 'true' && - (github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && - (needs.detect-changes.outputs.php == 'true' || - needs.detect-changes.outputs.php_tests == 'true' || - needs.detect-changes.outputs.php_deps == 'true' || - needs.detect-changes.outputs.ci == 'true') + inputs.back_sync == true || + (needs.detect-changes.outputs.has_php_integration_tests == 'true' && + (github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && + (needs.detect-changes.outputs.php == 'true' || + needs.detect-changes.outputs.php_tests == 'true' || + needs.detect-changes.outputs.php_deps == 'true' || + needs.detect-changes.outputs.ci == 'true')) services: mysql: @@ -291,11 +299,12 @@ jobs: runs-on: ubuntu-latest needs: detect-changes if: | - needs.detect-changes.outputs.has_js_tests == 'true' && - (needs.detect-changes.outputs.js == 'true' || - needs.detect-changes.outputs.js_tests == 'true' || - needs.detect-changes.outputs.npm_deps == 'true' || - needs.detect-changes.outputs.ci == 'true') + inputs.back_sync == true || + (needs.detect-changes.outputs.has_js_tests == 'true' && + (needs.detect-changes.outputs.js == 'true' || + needs.detect-changes.outputs.js_tests == 'true' || + needs.detect-changes.outputs.npm_deps == 'true' || + needs.detect-changes.outputs.ci == 'true')) steps: - name: Checkout diff --git a/docs/github-actions.md b/docs/github-actions.md index fb83c364e..7bdfc3a43 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -29,7 +29,7 @@ After those checks pass, `release.yml` builds one canonical ZIP and uses that sa - the review-required visual changelog draft; and - Slack status. -It also attempts to open a `master` to `develop` back-sync PR. A failed downstream step is visible and can be retried by manually running the workflow with the original merged PR number. +It also opens or reuses a `master` to `develop` back-sync PR, tests that PR's proposed merge, and waits for those tests before reporting success. A failed downstream step is visible and can be retried by manually running the workflow with the original merged PR number. Direct tags and direct pushes to `master` do not publish a plugin release. A merged release PR publishes only when authorized by a current maintainer approval or an authorized maintainer merge.