From 7e6820343f739c87b5fecc9470c63d4e82403603 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Wed, 30 Sep 2026 17:28:54 -0400 Subject: [PATCH 1/2] Guard WordPress.org publication workflows --- .github/release-profile.json | 23 +++ .github/workflows/deploy-readme-assets.yml | 148 +++++++++++-- .github/workflows/deploy-to-wordpress.yml | 63 ------ .github/workflows/draft-release.yml | 91 -------- .github/workflows/pr-target-check.yml | 71 +++++++ .github/workflows/publication-gate.yml | 136 ++++++++++++ .github/workflows/release.yml | 194 ++++++++++++++++++ .../workflows/test-deploy-to-wordpress.yml | 49 ----- bin/validate-release-version.js | 155 ++++++++++++++ package.json | 1 + readme.md | 12 +- .../unit/bin/validate-release-version.test.js | 86 ++++++++ .../unit/bin/verify-release-artifact.test.js | 3 + 13 files changed, 811 insertions(+), 221 deletions(-) create mode 100644 .github/release-profile.json delete mode 100644 .github/workflows/deploy-to-wordpress.yml delete mode 100644 .github/workflows/draft-release.yml create mode 100644 .github/workflows/pr-target-check.yml create mode 100644 .github/workflows/publication-gate.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/test-deploy-to-wordpress.yml create mode 100644 bin/validate-release-version.js create mode 100644 tests/unit/bin/validate-release-version.test.js diff --git a/.github/release-profile.json b/.github/release-profile.json new file mode 100644 index 00000000..d6a1d033 --- /dev/null +++ b/.github/release-profile.json @@ -0,0 +1,23 @@ +{ + "productionBranch": "master", + "developmentBranch": "develop", + "pluginSlug": "content-control", + "pluginName": "Content Control", + "mainFile": "content-control.php", + "versionTagPrefix": "v", + "versionSources": ["package.json", "content-control.php", "readme.txt"], + "changelogFiles": ["CHANGELOG.md", "readme.txt"], + "artifact": { + "root": "content-control", + "requiredPaths": ["content-control.php", "vendor/autoload.php", "dist/settings-page.js"], + "forbiddenPrefixes": [".git/", ".github/", "bin/", "docs/", "node_modules/", "packages/", "tests/"] + }, + "channels": { + "github": true, + "wordpressOrg": true, + "edd": false, + "googleDrive": false, + "changelogDraft": false, + "slack": false + } +} diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 7f89c9ba..2058a36b 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -1,26 +1,144 @@ -name: Plugin asset/readme update +name: Sync approved readme/assets PR to WordPress.org + on: - push: - branches: - - master + pull_request_target: + branches: [master] + types: [closed] + repository_dispatch: + types: [retry_approved_readme_assets_pr] + +permissions: + contents: read + pull-requests: read -env: - IGNORE_OTHER_FILES: true +concurrency: + group: content-control-publication + cancel-in-progress: false jobs: - master: - name: Push to master + authorize: + name: Authorize approved readme/assets PR + if: >- + github.event_name == 'repository_dispatch' || + (github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'wordpress-org/')) runs-on: ubuntu-latest + outputs: + should_sync: ${{ steps.authorize.outputs.should_sync }} + merge_sha: ${{ steps.authorize.outputs.merge_sha }} steps: - - name: Checkout master - uses: actions/checkout@v4 + - name: Verify merged PR, approval, branch, and exact file scope + id: authorize + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + RETRY_PULL_REQUEST_NUMBER: ${{ github.event.client_payload.pull_request_number }} + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const number = context.eventName === 'repository_dispatch' + ? Number(process.env.RETRY_PULL_REQUEST_NUMBER) + : context.payload.pull_request.number; + if (!Number.isSafeInteger(number) || number < 1) { + return core.setFailed('A valid merged readme/assets PR number is required.'); + } + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + core.setOutput('should_sync', 'false'); + 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}`) { + return core.setFailed('Publication PRs must come from this repository.'); + } + if (!/^wordpress-org\/[a-z0-9][a-z0-9._-]*$/.test(pull.head.ref)) { + return core.setFailed('Branch must be wordpress-org/ using lowercase letters, numbers, dots, underscores, or hyphens.'); + } + 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 candidates = [...latestByReviewer.entries()] + .filter(([, state]) => state === 'APPROVED') + .map(([login]) => login); + if (pull.merged_by?.login) candidates.push(pull.merged_by.login); + let authorized = false; + for (const username of [...new Set(candidates)]) { + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); + if (['admin', 'maintain', 'write'].includes(permission.permission)) { + authorized = true; + break; + } + } + if (!authorized) { + return core.setFailed('The PR was not approved or merged by an authorized maintainer.'); + } + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: number, per_page: 100, + }); + const allowedPath = (filename) => + filename === 'readme.txt' || filename.startsWith('.wordpress-org/'); + 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) => + allowedPath(file.filename) && + (file.status !== 'renamed' || allowedPath(file.previous_filename || '')) + ); + if (!allowed) { + return core.setFailed('WordPress.org publication PRs may only change readme.txt and .wordpress-org assets.'); + } + core.setOutput('merge_sha', pull.merge_commit_sha); + core.setOutput('should_sync', 'true'); + + update: + name: Update WordPress.org readme and assets + needs: authorize + if: needs.authorize.outputs.should_sync == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout approved merge + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.authorize.outputs.merge_sha }} + fetch-depth: 0 + persist-credentials: false + + - 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: Install SVN - run: sudo apt-get install -y subversion + - name: Validate publication files + run: | + node <<'NODE' + const fs = require('fs'); + const version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; + const stableTag = fs.readFileSync('readme.txt', 'utf8') + .match(/^Stable tag:\s*([^\s]+)\s*$/m)?.[1]; + if (stableTag !== version) throw new Error(`Stable tag ${stableTag || '(missing)'} does not match ${version}.`); + NODE + 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: WordPress.org plugin asset/readme update - 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 }} - SLUG: ${{ secrets.SLUG }} + SLUG: content-control + ASSETS_DIR: .wordpress-org + IGNORE_OTHER_FILES: true diff --git a/.github/workflows/deploy-to-wordpress.yml b/.github/workflows/deploy-to-wordpress.yml deleted file mode 100644 index 1cf845b0..00000000 --- a/.github/workflows/deploy-to-wordpress.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Deploy to WordPress.org - -on: - release: - types: [published] - -env: - BUILD_DIR: ./build - SLUG: ${{ vars.SLUG }} - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - # Checkout code - - name: Checkout code - uses: actions/checkout@v4 - - # Cache node modules - - name: Cache node modules - uses: ./.github/actions/restore-npm-cache - with: - cache-prefix: npm-deps-v1 - - # Setup Node 16 - - uses: actions/setup-node@v3 - with: - node-version: 16 - - # Install dependencies - - name: Install dependencies - run: npm i - - # Build release assets using our release:build script - - name: Build release assets - env: - COMPOSER_AUTH: '{}' - COMPOSER_HOME: ${{ runner.temp }}/composer-release - run: npm run release:build - - - name: Verify assembled WordPress.org tree - run: node bin/verify-release-artifact.js --release-tree "${{ env.BUILD_DIR }}" - - - name: Run WordPress Plugin Check - uses: wordpress/plugin-check-action@v1 - with: - build-dir: ${{ env.BUILD_DIR }} - slug: content-control - exclude-directories: | - vendor - vendor-prefixed - ignore-warnings: true - - # Deploy to WordPress.org - - name: WordPress Plugin Deploy - id: deploy - uses: 10up/action-wordpress-plugin-deploy@2.3.0 - # with: - # generate-zip: true - # dry-run: true - env: - SVN_USERNAME: ${{ secrets.SVN_USERNAME }} - SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml deleted file mode 100644 index 6a3d5e1a..00000000 --- a/.github/workflows/draft-release.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Draft Release - -on: - push: - tags: - - 'v*' - -env: - BUILD_DIR: build - SLUG: ${{ vars.SLUG }} - -jobs: - draft: - runs-on: ubuntu-latest - - # Define outputs for the job - outputs: - release_url: ${{ steps.create_release.outputs.html_url }} - - steps: - # Checkout code - - name: Checkout code - uses: actions/checkout@v4 - - # Cache node modules - - name: Cache node modules - uses: ./.github/actions/restore-npm-cache - with: - cache-prefix: npm-deps-v1 - - # Setup Node 16 - - uses: actions/setup-node@v3 - with: - node-version: 16 - - # Install dependencies & build release assets - - name: Install dependencies - run: npm i - - # Build release assets using our release:build script - - name: Build release assets - env: - COMPOSER_AUTH: '{}' - COMPOSER_HOME: ${{ runner.temp }}/composer-release - run: npm run release:build - - # zip the build folder into slug.zip - - name: Prepare zip file - run: | - mv $BUILD_DIR ${{ env.SLUG }} - zip -r ${{ env.SLUG }}_${{ github.ref_name }}.zip ${{ env.SLUG }} - - - name: Verify release zip - run: node bin/verify-release-artifact.js "${{ env.SLUG }}_${{ github.ref_name }}.zip" - - # Create draft release - - name: Create Draft Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: true - - # Display link to draft release visually - - name: Display Release Link - run: | - echo "Draft release URL: ${{ steps.create_release.outputs.html_url }}" - - # Upload release asset - - name: Upload Release Asset - id: upload-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ${{ env.SLUG }}_${{ github.ref_name }}.zip - asset_name: ${{ env.SLUG }}_${{ github.ref_name }}.zip - asset_content_type: application/zip - - # Additional job to display the link prominently at the end - display: - needs: draft - runs-on: ubuntu-latest - steps: - - name: Print Release URL - run: | - echo "The draft release can be found at: ${{ needs.draft.outputs.release_url }}" diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml new file mode 100644 index 00000000..1de5d07a --- /dev/null +++ b/.github/workflows/pr-target-check.yml @@ -0,0 +1,71 @@ +name: PR target check + +on: + pull_request_target: + types: [opened, reopened, edited, synchronize] + branches: [master] + +permissions: + contents: read + pull-requests: write + +jobs: + redirect: + runs-on: ubuntu-latest + if: >- + !(github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/')) + steps: + - name: Check for an authorized WordPress.org PR + id: target + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + result-encoding: string + script: | + const pull = context.payload.pull_request; + if (pull.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + return 'retarget'; + } + if (!/^wordpress-org\/[a-z0-9][a-z0-9._-]*$/.test(pull.head.ref)) { + return 'retarget'; + } + let permission = ''; + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: pull.user.login, + }); + permission = response.data.permission; + } catch (error) { + if (error.status !== 404) throw error; + } + if (!['admin', 'maintain', 'write'].includes(permission)) { + return 'retarget'; + } + const files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, + pull_number: pull.number, + per_page: 100, + }); + const allowedPath = (filename) => + filename === 'readme.txt' || filename.startsWith('.wordpress-org/'); + 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) => + allowedPath(file.filename) && + (file.status !== 'renamed' || allowedPath(file.previous_filename || '')) + ); + return allowed ? 'allow' : 'retarget'; + + - name: Retarget to develop + if: steps.target.outputs.result == 'retarget' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + gh pr edit "${PR_NUMBER}" --repo "${REPO}" --base develop + gh pr comment "${PR_NUMBER}" --repo "${REPO}" \ + --body "Heads up — this PR was retargeted to \`develop\`. Master only accepts full releases from \`release/X.Y.Z\` or readme/assets-only updates from \`wordpress-org/\`." diff --git a/.github/workflows/publication-gate.yml b/.github/workflows/publication-gate.yml new file mode 100644 index 00000000..5166be88 --- /dev/null +++ b/.github/workflows/publication-gate.yml @@ -0,0 +1,136 @@ +name: Publication Gate + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened, ready_for_review, edited] + +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: refs/pull/${{ github.event.pull_request.number }}/merge + 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: + 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 --no-renames "${BASE_SHA}" HEAD) + if [[ "${HEAD_BRANCH}" =~ ^release/([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + [ "${HEAD_REPOSITORY}" = "${REPOSITORY}" ] || { echo 'Release branches must be same-repository.'; exit 1; } + echo 'type=release' >> "${GITHUB_OUTPUT}" + echo "version=${BASH_REMATCH[1]}" >> "${GITHUB_OUTPUT}" + elif [[ "${HEAD_BRANCH}" =~ ^wordpress-org/[a-z0-9][a-z0-9._-]*$ ]]; then + [ "${HEAD_REPOSITORY}" = "${REPOSITORY}" ] || { echo 'WordPress.org branches must be same-repository.'; exit 1; } + [ -n "${CHANGED_FILES}" ] || { echo 'WordPress.org publication PR is empty.'; exit 1; } + ! echo "${CHANGED_FILES}" | grep -Ev '^(readme\.txt|\.wordpress-org/)' >/dev/null || { + echo 'WordPress.org branches may only change readme.txt and .wordpress-org assets.' + exit 1 + } + echo 'type=readme-assets' >> "${GITHUB_OUTPUT}" + else + echo 'type=none' >> "${GITHUB_OUTPUT}" + fi + + - name: Validate WordPress.org metadata + if: steps.publication.outputs.type == 'readme-assets' + run: | + node <<'NODE' + const fs = require('fs'); + const version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; + const stableTag = fs.readFileSync('readme.txt', 'utf8') + .match(/^Stable tag:\s*([^\s]+)\s*$/m)?.[1]; + if (stableTag !== version) throw new Error('Stable tag must match package.json.'); + NODE + + - 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 Node.js + if: steps.publication.outputs.type == 'release' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '16' + cache: npm + + - name: Install release dependencies + if: steps.publication.outputs.type == 'release' + run: npm ci + + - name: Validate, build, and verify release candidate + if: steps.publication.outputs.type == 'release' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + VERSION: ${{ steps.publication.outputs.version }} + COMPOSER_AUTH: '{}' + COMPOSER_HOME: ${{ runner.temp }}/composer-release + 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}" + npm run test:unit -- --runInBand tests/unit/bin/validate-release-version.test.js tests/unit/bin/verify-release-artifact.test.js + npm run release:build + mv build content-control + zip -rq "content-control_v${VERSION}.zip" content-control + mv content-control build + node bin/verify-release-artifact.js "content-control_v${VERSION}.zip" + + - name: Run WordPress Plugin Check + if: steps.publication.outputs.type == 'release' + uses: wordpress/plugin-check-action@10857da14b6c2246d15402b3e69f777edcf8c12e # v1 + with: + build-dir: ./build + slug: content-control + exclude-directories: | + vendor + vendor-prefixed + ignore-warnings: true + + - name: Upload release candidate + if: steps.publication.outputs.type == 'release' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: content-control-${{ steps.publication.outputs.version }}-pr-${{ github.event.pull_request.number }} + path: content-control_v${{ steps.publication.outputs.version }}.zip + retention-days: 30 + + - name: Explain publication path + env: + TYPE: ${{ steps.publication.outputs.type }} + VERSION: ${{ steps.publication.outputs.version }} + run: | + case "${TYPE}" in + release) echo "Merging this authorized release PR publishes Content Control ${VERSION}." >> "${GITHUB_STEP_SUMMARY}" ;; + readme-assets) echo 'Merging this authorized wordpress-org/ PR syncs only readme.txt and .wordpress-org assets.' >> "${GITHUB_STEP_SUMMARY}" ;; + *) echo 'Merging this PR publishes nothing.' >> "${GITHUB_STEP_SUMMARY}" ;; + esac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b4315495 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,194 @@ +name: Publish approved release PR + +on: + 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: content-control-publication + cancel-in-progress: false + +jobs: + 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.auth.outputs.merge_sha }} + base_sha: ${{ steps.auth.outputs.base_sha }} + version: ${{ steps.auth.outputs.version }} + pr: ${{ steps.auth.outputs.pr }} + steps: + - name: Verify merged PR authorization and immutable identity + id: auth + 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; + if (!Number.isSafeInteger(number) || number < 1) return core.setFailed('A valid merged PR is required.'); + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + if (!pull.merged || pull.base.ref !== 'master') return core.setFailed('Release PR must be merged into master.'); + if (pull.head.repo?.full_name !== `${owner}/${repo}`) return core.setFailed('Release PR must be same-repository.'); + const branch = pull.head.ref.match(/^release\/(\d+\.\d+\.\d+)$/); + if (!branch) return core.setFailed('Branch must be release/X.Y.Z.'); + const actor = pull.merged_by?.login; + if (!actor) return core.setFailed('An identified maintainer must merge the PR.'); + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: actor }); + if (!['admin', 'maintain', 'write'].includes(permission.permission)) return core.setFailed('Merger lacks release permission.'); + const { data: master } = await github.rest.repos.getBranch({ owner, repo, branch: 'master' }); + if (master.commit.sha !== pull.merge_commit_sha) return core.setFailed('Release is stale; master has advanced.'); + const version = branch[1]; + const { data: packageFile } = await github.rest.repos.getContent({ owner, repo, path: 'package.json', ref: 'master' }); + const declared = JSON.parse(Buffer.from(packageFile.content, 'base64').toString('utf8')).version; + if (declared !== version) return core.setFailed(`master declares ${declared}; expected ${version}.`); + const tagName = `v${version}`; + let tagged = ''; + try { + const { data: ref } = await github.rest.git.getRef({ owner, repo, ref: `tags/${tagName}` }); + tagged = ref.object.sha; + if (ref.object.type === 'tag') tagged = (await github.rest.git.getTag({ owner, repo, tag_sha: tagged })).data.object.sha; + } catch (error) { if (error.status !== 404) throw error; } + if (tagged && tagged !== pull.merge_commit_sha) return core.setFailed(`Tag ${tagName} points elsewhere.`); + core.setOutput('merge_sha', pull.merge_commit_sha); + core.setOutput('base_sha', pull.base.sha); + core.setOutput('version', version); + core.setOutput('pr', String(number)); + + build: + name: Build canonical package + needs: authorize + runs-on: ubuntu-latest + outputs: + zip: ${{ steps.package.outputs.zip }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.authorize.outputs.merge_sha }} + fetch-depth: 0 + persist-credentials: false + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.0' + tools: composer:v2 + coverage: none + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '16' + cache: npm + - run: npm ci + - name: Validate and package + id: package + env: + VERSION: ${{ needs.authorize.outputs.version }} + COMPOSER_AUTH: '{}' + COMPOSER_HOME: ${{ runner.temp }}/composer-release + run: | + PREVIOUS_VERSION=$(git show "${{ needs.authorize.outputs.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}" + npm run test:unit -- --runInBand tests/unit/bin/validate-release-version.test.js tests/unit/bin/verify-release-artifact.test.js + npm run release:build + mv build content-control + zip -rq "content-control_v${VERSION}.zip" content-control + mv content-control build + node bin/verify-release-artifact.js "content-control_v${VERSION}.zip" + echo "zip=content-control_v${VERSION}.zip" >> "${GITHUB_OUTPUT}" + - uses: wordpress/plugin-check-action@10857da14b6c2246d15402b3e69f777edcf8c12e # v1 + with: + build-dir: ./build + slug: content-control + exclude-directories: | + vendor + vendor-prefixed + ignore-warnings: true + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: content-control-${{ needs.authorize.outputs.version }} + path: ${{ steps.package.outputs.zip }} + retention-days: 30 + + github-release: + name: Create GitHub release + needs: [authorize, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: content-control-${{ needs.authorize.outputs.version }} + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: v${{ needs.authorize.outputs.version }} + target_commitish: ${{ needs.authorize.outputs.merge_sha }} + name: Release v${{ needs.authorize.outputs.version }} + generate_release_notes: true + files: ${{ needs.build.outputs.zip }} + draft: false + prerelease: false + + wordpress-org: + name: Deploy canonical package to WordPress.org + needs: [authorize, build, github-release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.authorize.outputs.merge_sha }} + persist-credentials: false + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: content-control-${{ needs.authorize.outputs.version }} + - name: Verify and extract canonical package + env: + VERSION: ${{ needs.authorize.outputs.version }} + run: | + node bin/verify-release-artifact.js "${{ needs.build.outputs.zip }}" + mkdir -p .wordpress-org-build + unzip -q "${{ needs.build.outputs.zip }}" -d .wordpress-org-build + - name: Deploy to WordPress.org SVN + uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # stable + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + SLUG: content-control + VERSION: ${{ needs.authorize.outputs.version }} + BUILD_DIR: ${{ github.workspace }}/.wordpress-org-build/content-control + + back-sync: + name: Open production back-sync PR + needs: [authorize, github-release, wordpress-org] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Open or reuse master to develop PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + VERSION: ${{ needs.authorize.outputs.version }} + run: | + NUMBER=$(gh pr list --repo "${REPO}" --base develop --head master --state open --json number --jq '.[0].number // ""') + if [ -z "${NUMBER}" ]; then + URL=$(gh pr create --repo "${REPO}" --base develop --head master --title "Back-sync release ${VERSION}" --body "Carries the approved ${VERSION} release merge back to the development branch.") + NUMBER=${URL##*/} + fi + echo "Back-sync PR: https://github.com/${REPO}/pull/${NUMBER}" >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/test-deploy-to-wordpress.yml b/.github/workflows/test-deploy-to-wordpress.yml deleted file mode 100644 index 0e8cf7b7..00000000 --- a/.github/workflows/test-deploy-to-wordpress.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Deploy to WordPress.org - -on: - release: - types: [unpublished] - -env: - BUILD_DIR: ./build - SLUG: ${{ vars.SLUG }} - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - # Checkout code - - name: Checkout code - uses: actions/checkout@v4 - - # Cache node modules - - name: Cache node modules - uses: ./.github/actions/restore-npm-cache - with: - cache-prefix: npm-deps-v1 - - # Setup Node 16 - - uses: actions/setup-node@v3 - with: - node-version: 16 - - # Install dependencies & build release assets - - name: Install dependencies - run: | - npm i - - # Build release assets - - name: Build release assets and copy to build directory - run: | - npm run build:release - - # Deploy to WordPress.org - - name: WordPress Plugin Deploy - id: deploy - uses: 10up/action-wordpress-plugin-deploy@2.2.2 - with: - # generate-zip: true - dry-run: true - env: - SVN_USERNAME: ${{ secrets.SVN_USERNAME }} - SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} diff --git a/bin/validate-release-version.js b/bin/validate-release-version.js new file mode 100644 index 00000000..002b261f --- /dev/null +++ b/bin/validate-release-version.js @@ -0,0 +1,155 @@ +#!/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 hasValidDatedHeading( contents, pattern ) { + const match = contents.match( pattern ); + + if ( ! match ) { + return false; + } + + const month = Number( match[ 1 ] ); + const day = Number( match[ 2 ] ); + const year = 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, + 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 plugin = readFile( projectRoot, 'content-control.php' ); + const readme = readFile( projectRoot, 'readme.txt' ); + const changelog = readFile( projectRoot, 'CHANGELOG.md' ); + const versions = { + 'package.json': packageVersion, + 'content-control.php header': extractMatch( + plugin, + /^\s*\*\s*Version:\s*([^\s]+)\s*$/m, + 'plugin header version' + ), + 'content-control.php config': extractMatch( + plugin, + /'version'\s*=>\s*'([^']+)'/, + 'plugin config version' + ), + 'readme.txt stable tag': extractMatch( + readme, + /^Stable tag:\s*([^\s]+)\s*$/m, + 'readme stable tag' + ), + }; + const mismatches = Object.entries( versions ).filter( + ( [ , found ] ) => found !== version + ); + + if ( mismatches.length ) { + throw new Error( + `Version mismatch: ${ mismatches + .map( ( [ label, found ] ) => `${ label }=${ found }` ) + .join( ', ' ) }; expected ${ version }.` + ); + } + + const escapedVersion = version.replace( /\./g, '\\.' ); + const date = '(\\d{2})/(\\d{2})/(\\d{4})'; + if ( + ! hasValidDatedHeading( + changelog, + new RegExp( `^## v${ escapedVersion } - ${ date }$`, 'm' ) + ) + ) { + throw new Error( `CHANGELOG.md has no valid dated v${ version } entry.` ); + } + if ( + ! hasValidDatedHeading( + readme, + new RegExp( `^= v${ escapedVersion } - ${ date } =$`, 'm' ) + ) + ) { + throw new Error( `readme.txt has no valid dated v${ version } changelog entry.` ); + } + + return versions; +} + +function getArgument( name ) { + const index = process.argv.indexOf( name ); + return -1 === index ? '' : 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 and valid.` ); + } catch ( error ) { + console.error( error.message ); + process.exit( 1 ); + } +} + +module.exports = { compareVersions, validateReleaseVersion }; + +/* eslint-enable no-console */ diff --git a/package.json b/package.json index a5385a1f..b8863900 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "pretest:unit:php": "wp-env start", "test:unit:php": "wp-env run phpunit /var/www/html/wp-content/plugins/content-control/vendor/bin/phpunit -c /var/www/html/wp-content/plugins/content-control/phpunit.xml.dist --verbose", "translate": "wp-pot --src './classes/**/*.php' --src '!./build/**/*.*' --src '!./vendor/**/*.*' --src '!./dist/**/*.*' --write=true --dest-file 'languages/content-control.pot' --package 'Content Control' --domain 'content-control' --last-translator 'Code Atlantic ' --team 'Code Atlantic ' --bug-report 'https://code-atlantic.com/contact/'", + "validate:release-version": "node bin/validate-release-version.js", "verify:release-artifact": "node bin/verify-release-artifact.js", "verify:release-tree": "node bin/verify-release-artifact.js --release-tree build", "release:build": "composer install -o --no-dev --classmap-authoritative && npm run build:release && npm run copy-build && npm run verify:release-tree", diff --git a/readme.md b/readme.md index ab850003..f28b5c61 100644 --- a/readme.md +++ b/readme.md @@ -31,8 +31,13 @@ Task info coming soon.... ## 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 publishes from a same-repository `release/X.Y.Z` pull request into +`master`. The publication gate validates the proposed merge and builds a release +candidate. After an authorized merge, one verified ZIP is published to GitHub +and WordPress.org, then a back-sync pull request is opened against `develop`. + +Metadata-only updates use a `wordpress-org/` pull request into `master` +and may only change `readme.txt` and `.wordpress-org/`. ## Contributing @@ -52,7 +57,8 @@ Please Note: GitHub is not intended for support based questions. For those, plea - Ensure you stick to the [WordPress Coding Standards](https://codex.wordpress.org/WordPress_Coding_Standards) - When committing, reference your issue (if present) and include a note about the fix -- Push the changes to your fork and submit a pull request to the 'master' branch of this repository +- Submit normal feature, fix, and maintenance pull requests to `develop` +- Reserve `master` for same-repository `release/X.Y.Z` and `wordpress-org/` publication pull requests - We are trying to ensure that every function is documented well and follows the standards set by phpDoc going forward ## Versioning 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 00000000..b03eee77 --- /dev/null +++ b/tests/unit/bin/validate-release-version.test.js @@ -0,0 +1,86 @@ +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(), 'content-control-release-version-' ) + ); + fs.writeFileSync( + path.join( projectRoot, 'package.json' ), + JSON.stringify( { version: '2.7.4' } ) + ); + fs.writeFileSync( + path.join( projectRoot, 'content-control.php' ), + " * Version: 2.7.4\n'version' => '2.7.4',\n" + ); + fs.writeFileSync( + path.join( projectRoot, 'readme.txt' ), + 'Stable tag: 2.7.4\n\n= v2.7.4 - 09/20/2026 =\n' + ); + fs.writeFileSync( + path.join( projectRoot, 'CHANGELOG.md' ), + '## v2.7.4 - 09/20/2026\n' + ); + } ); + + afterEach( () => { + fs.rmSync( projectRoot, { recursive: true, force: true } ); + } ); + + test( 'accepts one consistent version newer than the base release', () => { + expect( + validateReleaseVersion( { + projectRoot, + version: '2.7.4', + previousVersion: '2.7.3', + } ) + ).toMatchObject( { + 'package.json': '2.7.4', + 'readme.txt stable tag': '2.7.4', + } ); + } ); + + test( 'rejects mismatched release files', () => { + fs.writeFileSync( + path.join( projectRoot, 'readme.txt' ), + 'Stable tag: 2.7.3\n\n= v2.7.4 - 09/20/2026 =\n' + ); + expect( () => + validateReleaseVersion( { projectRoot, version: '2.7.4' } ) + ).toThrow( 'readme.txt stable tag=2.7.3' ); + } ); + + test( 'rejects releases that do not advance the base version', () => { + expect( () => + validateReleaseVersion( { + projectRoot, + version: '2.7.4', + previousVersion: '2.7.4', + } ) + ).toThrow( 'must be newer' ); + } ); + + test( 'rejects impossible release dates', () => { + fs.writeFileSync( + path.join( projectRoot, 'CHANGELOG.md' ), + '## v2.7.4 - 99/99/2026\n' + ); + expect( () => + validateReleaseVersion( { projectRoot, version: '2.7.4' } ) + ).toThrow( 'no valid dated' ); + } ); + + test( 'compares semantic version parts numerically', () => { + expect( compareVersions( '2.7.4', '2.7.3' ) ).toBeGreaterThan( 0 ); + expect( compareVersions( '3.0.0', '2.99.99' ) ).toBeGreaterThan( 0 ); + } ); +} ); diff --git a/tests/unit/bin/verify-release-artifact.test.js b/tests/unit/bin/verify-release-artifact.test.js index c25642cc..fc559ff9 100644 --- a/tests/unit/bin/verify-release-artifact.test.js +++ b/tests/unit/bin/verify-release-artifact.test.js @@ -21,6 +21,7 @@ describe( 'release artifact verifier', () => { try { for ( const relativePath of [ + 'composer.json', 'content-control.php', 'readme.txt', 'dist/settings-page.js', @@ -101,6 +102,7 @@ describe( 'release artifact verifier', () => { test( 'allows only the runtime Composer records copied by the build', () => { expect( verifyReleaseManifest( [ + 'content-control/composer.json', 'content-control/content-control.php', 'content-control/readme.txt', 'content-control/dist/settings-page.js', @@ -127,6 +129,7 @@ describe( 'release artifact verifier', () => { try { for ( const relativePath of [ + 'composer.json', 'content-control.php', 'readme.txt', 'dist/settings-page.js', From 0e14b3735c24832b4cf6a6a57de48d3764e243bc Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 1 Oct 2026 01:53:29 -0400 Subject: [PATCH 2/2] Fix publication workflow review findings --- .github/workflows/pr-target-check.yml | 6 +++--- .github/workflows/publication-gate.yml | 2 +- .github/workflows/release.yml | 23 +++++++++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 1de5d07a..d4dff83f 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -12,9 +12,6 @@ permissions: jobs: redirect: runs-on: ubuntu-latest - if: >- - !(github.event.pull_request.head.repo.full_name == github.repository && - startsWith(github.event.pull_request.head.ref, 'release/')) steps: - name: Check for an authorized WordPress.org PR id: target @@ -26,6 +23,9 @@ jobs: if (pull.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { return 'retarget'; } + if (/^release\/[0-9]+\.[0-9]+\.[0-9]+$/.test(pull.head.ref)) { + return 'allow'; + } if (!/^wordpress-org\/[a-z0-9][a-z0-9._-]*$/.test(pull.head.ref)) { return 'retarget'; } diff --git a/.github/workflows/publication-gate.yml b/.github/workflows/publication-gate.yml index 5166be88..0e71a471 100644 --- a/.github/workflows/publication-gate.yml +++ b/.github/workflows/publication-gate.yml @@ -49,7 +49,7 @@ jobs: elif [[ "${HEAD_BRANCH}" =~ ^wordpress-org/[a-z0-9][a-z0-9._-]*$ ]]; then [ "${HEAD_REPOSITORY}" = "${REPOSITORY}" ] || { echo 'WordPress.org branches must be same-repository.'; exit 1; } [ -n "${CHANGED_FILES}" ] || { echo 'WordPress.org publication PR is empty.'; exit 1; } - ! echo "${CHANGED_FILES}" | grep -Ev '^(readme\.txt|\.wordpress-org/)' >/dev/null || { + ! echo "${CHANGED_FILES}" | grep -Ev '^(readme\.txt$|\.wordpress-org/)' >/dev/null || { echo 'WordPress.org branches may only change readme.txt and .wordpress-org assets.' exit 1 } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4315495..820755d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,8 +67,11 @@ jobs: if (ref.object.type === 'tag') tagged = (await github.rest.git.getTag({ owner, repo, tag_sha: tagged })).data.object.sha; } catch (error) { if (error.status !== 404) throw error; } if (tagged && tagged !== pull.merge_commit_sha) return core.setFailed(`Tag ${tagName} points elsewhere.`); + const { data: mergeCommit } = await github.rest.repos.getCommit({ owner, repo, ref: pull.merge_commit_sha }); + const baseSha = mergeCommit.parents?.[0]?.sha; + if (!baseSha) return core.setFailed('Release merge has no parent commit.'); core.setOutput('merge_sha', pull.merge_commit_sha); - core.setOutput('base_sha', pull.base.sha); + core.setOutput('base_sha', baseSha); core.setOutput('version', version); core.setOutput('pr', String(number)); @@ -141,6 +144,8 @@ jobs: name: Release v${{ needs.authorize.outputs.version }} generate_release_notes: true files: ${{ needs.build.outputs.zip }} + overwrite_files: false + fail_on_unmatched_files: true draft: false prerelease: false @@ -148,14 +153,19 @@ jobs: name: Deploy canonical package to WordPress.org needs: [authorize, build, github-release] runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.authorize.outputs.merge_sha }} persist-credentials: false - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: content-control-${{ needs.authorize.outputs.version }} + - name: Download canonical package from GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: v${{ needs.authorize.outputs.version }} + ZIP_NAME: ${{ needs.build.outputs.zip }} + run: gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" --pattern "${ZIP_NAME}" --dir . - name: Verify and extract canonical package env: VERSION: ${{ needs.authorize.outputs.version }} @@ -188,6 +198,11 @@ jobs: run: | NUMBER=$(gh pr list --repo "${REPO}" --base develop --head master --state open --json number --jq '.[0].number // ""') if [ -z "${NUMBER}" ]; then + AHEAD=$(gh api "repos/${REPO}/compare/develop...master" --jq '.ahead_by') + if [ "${AHEAD}" = "0" ]; then + echo "develop already contains master." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi URL=$(gh pr create --repo "${REPO}" --base develop --head master --title "Back-sync release ${VERSION}" --body "Carries the approved ${VERSION} release merge back to the development branch.") NUMBER=${URL##*/} fi