ci: auto-tag release commits and fix publish-svn permissions#424
Conversation
Adds a tag-on-merge workflow that pushes a vX.Y.Z tag when a
"chore: Release X.Y.Z" commit lands on main or v2, so the tag-
triggered publish-svn workflows fire automatically and the
release pipeline runs end-to-end without a manual git tag step.
The tag is pushed using GH_PUSH_TOKEN; tags pushed with the
default GITHUB_TOKEN do not trigger other workflows.
Also fixes publish-svn{,-v2}.yml: the github-release reusable
workflow declares pull-requests: read, but the caller jobs only
granted contents: write, causing startup_failure with:
The workflow is requesting 'pull-requests: read', but is only
allowed 'pull-requests: none'.
There was a problem hiding this comment.
LGTM — straightforward CI fix (permissions) plus a small, idempotent tag-on-merge workflow.
Extended reasoning...
Overview
This PR makes two CI-only changes: (1) adds pull-requests: read permission to the github-release job in both publish-svn.yml and publish-svn-v2.yml (a one-line permissions fix for a documented startup failure), and (2) adds a new tag-on-release-merge.yml workflow that watches main and v2 for chore: Release X.Y.Z commits and pushes the corresponding vX.Y.Z tag using GH_PUSH_TOKEN so downstream tag-triggered workflows fire.
Security risks
Minimal. The new workflow only triggers on push to main/v2 (both protected via PR-only merges), uses a regex anchored to the full subject line, and exits cleanly if the tag exists. The permission addition is the narrowest scope (read) needed by the called shared workflow. GH_PUSH_TOKEN is a PAT that already exists in the repo's secrets and is used elsewhere — no new credentials introduced.
Level of scrutiny
Moderate — this is release/deploy infrastructure, but the changes themselves are small, mechanical, and well-scoped. The permissions fix is unambiguously correct. The new tag workflow's logic is short enough to read end-to-end and matches the documented behavior in the PR description.
Other factors
The one bug surfaced by the bug-hunting pass is explicitly filed as a nit — a defense-in-depth gap where mistyping a major version into the wrong of two near-identical workflow_dispatch UIs could cross-publish trees. The inline comment captures the exploit path and a two-line guard; the author can decide whether to add it now or in a follow-up. Not a blocker for this PR.
| VERSION=$(echo "$SUBJECT" | sed -nE 's/^chore: Release ([0-9]+\.[0-9]+\.[0-9]+)( \(#[0-9]+\))?$/\1/p') | ||
| if [ -z "$VERSION" ]; then | ||
| echo "Commit subject is not a release commit; skipping: $SUBJECT" | ||
| exit 0 | ||
| fi |
There was a problem hiding this comment.
🟡 Defense-in-depth gap: the workflow extracts any X.Y.Z from chore: Release X.Y.Z and pushes vX.Y.Z without verifying that the major matches the branch (main → 3.x, v2 → 2.x). Because the version input to release.yml / release-v2.yml is a free-form string with no validation, an operator running the wrong Release workflow (e.g. Release (v2) with 3.9.1) would land a chore: Release 3.9.1 commit on v2; this job would then push tag v3.9.1 at a v2 commit, and publish-svn.yml (which has no trunk-version guard, unlike its v2 sibling) would silently deploy v2 source to the v3 trunk on wordpress.org. Consider rejecting when the version major doesn't match the branch (fail if [[ $GITHUB_REF_NAME == main && $VERSION != 3.* ]] || [[ $GITHUB_REF_NAME == v2 && $VERSION != 2.* ]]).
Extended reasoning...
What the bug is
tag-on-release-merge.yml triggers on push to both main and v2 and extracts the version with this regex:
VERSION=$(echo "$SUBJECT" | sed -nE 's/^chore: Release ([0-9]+\.[0-9]+\.[0-9]+)( \(#[0-9]+\))?$/\1/p')It then pushes v$VERSION at $GITHUB_SHA with no cross-check that the major component of VERSION matches the branch the commit landed on. Because publish-svn.yml triggers on v3.* tags and publish-svn-v2.yml on v2.* tags, a tag whose major doesn't match its commit's branch routes the wrong source tree to the wrong WordPress.org slot.
The code path that triggers it
release.yml and release-v2.yml both expose a free-form string version input via workflow_dispatch with no validation:
version:
description: 'Version to release (e.g., 3.9.0)'
required: true
type: stringrelease.yml hardcodes target_branch: main; release-v2.yml hardcodes target_branch: v2. The branch is locked, but the version isn't validated against it.
Step-by-step proof (mistyped version)
- Operator opens the Actions UI and runs Release (v2) with
version: 3.9.1(typo / wrong workflow). release-v2.ymlcallsprep-release.ymlwithversion=3.9.1, target_branch=v2, which opens a PR titledchore: Release 3.9.1 (#NNN)againstv2. The bump-version step only edits version files on a branch offv2, so the PR diff is small and looks normal.- PR is squash-merged into
v2. The merge commit subject ischore: Release 3.9.1 (#NNN). tag-on-release-merge.ymlfires on the push tov2. The regex extracts3.9.1. The workflow pushes tagv3.9.1at the v2 head SHA.publish-svn.ymltriggers on thev3.*tag, checks outv3.9.1(= a v2 commit),rsyncs the v2 source tree into/tmp/release-files, andsvn commits it to the v3trunkon wordpress.org as version 3.9.1.
The mirror case (Release (v3) with version 2.4.6) lands a chore: Release 2.4.6 commit on main, pushes v2.4.6, and triggers publish-svn-v2.yml. In that direction, the Verify trunk is untouched step (publish-svn-v2.yml:110-124) would catch it — but the v3 direction has no equivalent guard.
Why existing code doesn't prevent it
- The release workflows lock
target_branchbut notmajor(version) == branch_major. tag-on-release-merge.ymlonly checksstartsWith(message, 'chore: Release ')and regex shape — not major-vs-branch.publish-svn.ymlhas no trunk-version sanity check (only its v2 sibling does), so the v3 direction silently publishes.
Addressing the refutation
The refutation argues this requires manually retargeting the auto-PR base branch, which is implausible. That scenario is indeed implausible — but it isn't the only path. The mistyped-version-input path above doesn't require retargeting anything; it only requires running the wrong of two near-identically-named release workflows or making a typo on the major digit. That's a normal-human-error category (the inputs are free-form strings on workflows named Release (v2) and Release (v3) sitting next to each other in the Actions list). The refutation also claims the payload outcome is technically incorrect because the bump-version branch would carry v3 code; that's exactly right and exactly the problem — v3 code (the entire v3/ tree) gets published to wordpress.org under a v2 version number (or vice versa).
The refutation's point that the right place to harden against this is the prep workflow (validate version major against target_branch) is fair; either layer works. A guard here is the simpler and more local change since this PR introduces the new automatic-tag step.
Impact
A successful misroute publishes the wrong major's source tree to the WordPress.org plugin directory under the wrong version number. Reverting an SVN trunk commit and a wordpress.org-visible plugin version is operationally painful and user-visible. The v3 direction is unguarded.
How to fix
Two-line guard before pushing the tag:
case "$GITHUB_REF_NAME" in
main) [[ "$VERSION" == 3.* ]] || { echo "::error::version $VERSION on main; expected 3.x"; exit 1; } ;;
v2) [[ "$VERSION" == 2.* ]] || { echo "::error::version $VERSION on v2; expected 2.x"; exit 1; } ;;
esacThis converts the silent mis-publish into a CI failure on the tag step, before anything reaches SVN.
Severity
Filing as nit: requires operator error, but the fix is trivial and the blast radius (wrong major published to wordpress.org with no v3-side guard) is non-trivial.
One Line Summary
Adds a tag-on-merge workflow so the release pipeline runs end-to-end without manual
git tag, and fixes apull-requestspermission bug that was causingpublish-svn.ymlto fail at startup.Motivation
The release flow had two gaps that surfaced when shipping v3.9.0:
No automatic tagging after the release PR merges.
release.yml(andrelease-v2.yml) only opens the release PR. Nothing pushes thevX.Y.Ztag once it merges, so the tag-triggeredpublish-svn.ymlworkflow never fires, which means no WordPress.org SVN deploy and no GitHub release. For v3.9.0 I had to manuallygit tag v3.9.0 <sha> && git push origin v3.9.0and manually create the GitHub release.publish-svn.ymlhas a permissions bug. When I pushedv3.9.0to verify the tag-triggered path,publish-svn.ymlfailed at startup with:The shared
github-release.ymldeclarespull-requests: read(needed bygithub.rest.pulls.list()to look up the release PR body), but the callinggithub-releasejob inpublish-svn.ymlonly grantedcontents: write. Same bug exists inpublish-svn-v2.yml.Scope
.github/workflows/tag-on-release-merge.ymltriggers on push tomain/v2, parses the squash-merge subject forchore: Release X.Y.Z (#NNN), and pushes thevX.Y.Ztag usingGH_PUSH_TOKEN(the defaultGITHUB_TOKENcannot trigger downstream workflows on tag push).publish-svn.ymlandpublish-svn-v2.yml: addpull-requests: readto thegithub-releasejob permissions.release.yml,release-v2.yml, the SVN deploy logic itself.Testing
Manual
This PR is what the next release would exercise. Validation plan once merged:
Tag on Release Mergeis listed under Actions.main, verify:Tag on Release Mergeruns and pushesv3.9.1.Publish to WordPress.org (v3 trunk)triggers from the tag push.publishandgithub-releasejobs complete (the latter previously gotstartup_failure).3.9.1is created automatically.Local sanity checks on the new workflow:
chore: Release 3.9.0 (#423)and extracts3.9.0.fix: ...) are skipped via the job-levelif.Unit / Integration
composer testnot applicable to this PRAffected code checklist
v3/)v2/legacy codereadme.txt, etc.)Checklist
readme.txtand plugin version bumped if user-facing — N/AFollow-ups (not in this PR)
SVN_USERNAME/SVN_PASSWORDsecrets at the repo or visible org level. The SVN deploy step inpublish-svn.ymlwill still fail on the next release if those aren't configured. Worth confirming whether they live in a restricted org scope.