From 1dee26dd16a7cb025ead9582354b1f928ba83b79 Mon Sep 17 00:00:00 2001 From: Pablo Ariel Di Loreto Date: Mon, 28 Sep 2026 01:49:56 +0000 Subject: [PATCH 1/3] feat(release): one versioning policy for every repository, and main names the version it released Every DiluxOne repository now numbers its versions by one written policy (CONTRIBUTING.md, "How a change becomes a version"): a major for a big new capability or for anything that breaks, a minor for additions to what exists, a patch for fixes, security and performance; a breaking change ships only in a major, announced in a minor before it, and every major says in its changelog whether it breaks anything. A big capability is the maintainer's call with the version:major label; the review now says when a feat looks like one, and is told that size is not breakage, so a large compatible feature stays a feat. The version markers on main (the Version header, the PHP constant, Stable tag) now always name a real version. The release job stamped them only in its own checkout and never wrote main, so after 2.0.0 shipped, DiluxOne Offload's main still said 1.0.0, while every document said main keeps the last released version. Now the release pull request, the one that removes the Unreleased. line, sets them to the version it releases (scripts/release-markers.sh prepare), the checks' readme job holds every pull request to it (the last release, or the next one once its release pull request is ready), and the release job refuses to deploy a commit whose markers name another version. No extra pull request after a release, and main is right from the moment the release is decided. This enforces what the documentation already promised (main keeps the last released version), so a repository that followed it needs nothing; the one caller today, DiluxOne Offload, records 2.0.0 in its own pull request right after this. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/claude-review.yml | 7 +- .github/workflows/plugin-checks-wp.yml | 32 ++++- .github/workflows/plugin-release-wp.yml | 30 +++-- .github/workflows/pull-request.yml | 1 + CONTRIBUTING.md | 19 ++- README.md | 11 +- review-profiles/general.md | 9 ++ scripts/release-markers.sh | 164 ++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 scripts/release-markers.sh diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index ee8e30d..4274d26 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -377,8 +377,11 @@ jobs: Two more verdicts, both about the whole pull request (not only the new commits): `type`, the kind of change the diff really is, by the Conventional Commits meaning (breaking when a user or a caller must change something to keep working; feat when - behaviour is added; fix when wrong behaviour is corrected, whatever the title says; - docs, test, ci, build, chore, style, refactor, perf, revert when that is all it is); and + behaviour is added, however large, since size is not breakage; fix when wrong behaviour + is corrected, whatever the title says; docs, test, ci, build, chore, style, refactor, + perf, revert when that is all it is). When a feat looks like a new capability (a new + provider, a flow the product did not have), say so in one line of the summary as a + suggestion of the version:major label; the maintainer decides it, never you. And `description_matches`, true only if the description's "What changes" and "Why" describe what the diff does, with nothing claimed that the code does not do and no behaviour change left unsaid. When they disagree, say in the summary in one line diff --git a/.github/workflows/plugin-checks-wp.yml b/.github/workflows/plugin-checks-wp.yml index ea44d9d..6533b11 100644 --- a/.github/workflows/plugin-checks-wp.yml +++ b/.github/workflows/plugin-checks-wp.yml @@ -3,7 +3,8 @@ name: WordPress plugin checks (fast) # Reusable. The fast quality gates for a WordPress plugin published on # wordpress.org: PHP syntax and unit tests on every supported PHP, PHPCS, # PHPStan, Psalm taint analysis, i18n, Plugin Check on the shipped tree, and -# readme/version alignment. Each job is its own status check. +# readme/version alignment, and the version markers against what was +# released (scripts/release-markers.sh). Each job is its own status check. # # The slow wp-env suites (integration, E2E) are plugin-tests-wp.yml, a # separate workflow, so a caller can start the review as soon as these pass @@ -251,18 +252,28 @@ jobs: slug: ${{ inputs.slug }} categories: plugin_repo,security,performance,accessibility,general include-experimental: true - # main carries a -dev Version between releases; the readme job - # enforces the relaxed rule and the release workflow the strict one. + # The shipped tree here is the checkout, whose markers the readme + # job holds to the released version (or the one being released); + # the release workflow checks them again before SVN. ignore-codes: stable_tag_mismatch readme: name: readme.txt and versions runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + pull-requests: read # the labels of what merged give the version being released steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: DiluxOne/.github + ref: ${{ inputs.central-ref }} + path: .dx-central + persist-credentials: false - name: Headers, version alignment, changelog env: MAIN: ${{ inputs.main-file != '' && inputs.main-file || format('{0}.php', inputs.slug) }} @@ -287,4 +298,19 @@ jobs: fi grep -qE "^=\s*${stable}\s*=" readme.txt || { echo "::error file=readme.txt::No '= ${stable} =' changelog entry."; exit 1; } echo "readme.txt OK: Stable tag $stable, Version $version." + - name: The markers name the released version + # The three markers say the last version released, or, in the pull + # request that releases the next one (it removes the `Unreleased.` + # line) and on main after it merged, that next one; never a version + # that is not one (scripts/release-markers.sh). + env: + GH_TOKEN: ${{ github.token }} + MAIN: ${{ inputs.main-file != '' && inputs.main-file || format('{0}.php', inputs.slug) }} + CONSTANT: ${{ inputs.version-constant }} + BASE: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + next=$(python3 .dx-central/scripts/next-version.py --repo "$GITHUB_REPOSITORY" --base "$BASE" --json) + bash .dx-central/scripts/release-markers.sh check . "$MAIN" "$CONSTANT" \ + "$(jq -r .last <<<"$next")" "$(jq -r .next <<<"$next")" "$(jq -r .pending <<<"$next")" diff --git a/.github/workflows/plugin-release-wp.yml b/.github/workflows/plugin-release-wp.yml index 4904791..a1cbf1b 100644 --- a/.github/workflows/plugin-release-wp.yml +++ b/.github/workflows/plugin-release-wp.yml @@ -12,12 +12,15 @@ name: WordPress plugin release # `release:` says `off` for that bump: the run ends there, green, and the # summary says why. Something pending: the `release` job waits for the # environment's reviewers (the summary shows the version, the bump and the -# pull requests that justify it), then stamps the three version markers to -# X.Y.Z in the checkout, validates them, commits trunk + tags/X.Y.Z + -# assets to SVN, then creates the tag and the GitHub release in one call -# with the release App's token, with the changelog and what was merged, -# grouped by type. Nothing is ever committed to main: the markers there -# stay at the last released version and every development build stamps +# pull requests that justify it), then checks that the three version markers +# already say X.Y.Z (the release pull request, the one that removed the +# `Unreleased.` line, set them: scripts/release-markers.sh prepare), commits +# trunk + tags/X.Y.Z + assets to SVN, then creates the tag and the GitHub +# release in one call with the release App's token, with the changelog and +# what was merged, grouped by type. Nothing is ever committed to main by +# this workflow: the markers there say the last released version, or the +# one being released once its pull request merged (the checks' readme job +# holds every pull request to that), and every development build stamps # itself. After the approval the job checks again that this is still the # release to make: the tag does not exist, the labels still give this # version, the commit is on the default branch. A push made while a run @@ -418,15 +421,18 @@ jobs: [ "$again" = "$VERSION" ] || { echo "::error::The labels now give '${again:-nothing}' where $VERSION was approved; something merged or was relabelled meanwhile. Nothing reached wordpress.org; the next push to $BASE computes again."; exit 1; } echo "Still $VERSION, on $BASE, no tag yet." - - name: Stamp the version markers + - name: The markers say this version if: steps.still.outputs.go == 'true' - # The checkout, never the repository: the three markers become the - # version, a readme entry headed `= Unreleased =` takes its number and - # the readme is brought to LF (scripts/stamp-version.sh, which fails - # when a marker did not take it: nothing reaches wordpress.org from a - # file whose shape changed). + # The release pull request set the three markers and the changelog + # heading to this version, so main already says what ships: nothing + # reaches wordpress.org from a commit that names another version + # (scripts/release-markers.sh). The stamp that follows then changes + # nothing but the readme's line endings (LF), and fails when a marker + # does not hold the version: a file whose shape changed. run: | set -euo pipefail + last=$(git tag -l '[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -vxF "$VERSION" | head -1 || true) + bash .dx-central/scripts/release-markers.sh check . "$MAIN" "$CONSTANT" "${last:-0.0.0}" "$VERSION" true bash .dx-central/scripts/stamp-version.sh . "$VERSION" "$MAIN" "$CONSTANT" git --no-pager diff --stat diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1660f1d..f325199 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -47,6 +47,7 @@ jobs: - run: python3 scripts/next-version.py --test - run: bash scripts/release-ready.sh --test - run: bash scripts/stamp-version.sh --test + - run: bash scripts/release-markers.sh --test review: needs: [conventions, actionlint, scripts] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58e9d49..85ae8cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,11 +71,26 @@ Pull requests from **forks** are not reviewed automatically: the review runs wit ## How a change becomes a version -Nobody types a version number. The `type:*` label the review sets on each merged pull request decides the next one (`type:breaking` → major, `type:feat` → minor, `type:fix` or `type:perf` → patch; a maintainer's `version:major|minor|patch` label wins), and `main` keeps the last released version in its files between releases. In a repository that publishes (a WordPress plugin): +Every DiluxOne repository numbers its versions `X.Y.Z`, always three numbers, no suffix, by what a change means to the people who use it: + +| Part | When | Who decides | +| --- | --- | --- | +| **Major** `X.0.0` | A big new capability (a new provider, a new flow the product did not have), **or** any change that breaks something that worked | The maintainer, with the `version:major` label on the pull request. A breaking change (`type:breaking`, `!` in the title) forces it on its own | +| **Minor** `x.Y.0` | Additions and improvements to what already exists, compatible with it | Automatic: `type:feat` | +| **Patch** `x.y.Z` | Bug fixes, security fixes and performance, with no new behaviour | Automatic: `type:fix`, `type:perf` | + +Two rules come with it: + +- **A breaking change ships only in a major**, and a minor before it announces it ("this is going away in the next major"). Breaking means a user, a site or a caller has to change something to keep working: a new minimum requirement, a removed option, a setting that changes meaning, a renamed hook, data that cannot go back to the previous version. +- **Every major says in its changelog** `Breaking changes: none`, or lists them with what to do. + +"Big" is a person's call: the review suggests `version:major` when a pull request looks like a new capability, and never sets it. A big `feat` is still a `feat`, not a breaking change. + +Nobody types a version number. The `type:*` label the review sets on each merged pull request decides the next one (`type:breaking` → major, `type:feat` → minor, `type:fix` or `type:perf` → patch; a maintainer's `version:major|minor|patch` label wins). `main` names a real version in its files: the last one released, or, from the moment the pull request that releases the next one merges, that next one; the checks hold every pull request to it. In a repository that publishes (a WordPress plugin): - **Every push to `main` publishes a development build**, the shipped tree stamped `-dev.`, as the one *Development build* pre-release in the repository's Releases, replaced each time (no history: the commit in its notes rebuilds any of them). Anyone can download it and try what is coming; it is not a release, and "Latest" stays the last published version. - **The changelog is written as the changes merge.** A pull request that changes what a user sees adds its bullet to the newest entry of `readme.txt` (`= X.Y.Z =`, first line `Unreleased.`), in the same pull request. -- **The maintainer decides when it is ready** by removing the `Unreleased.` line in a pull request. That push to `main` waits for approval in the repository's `wordpress-org` environment; only its required reviewers can approve, and approving publishes (a repository's policy can set a kind of bump to `auto`, published without waiting, or `off`, never published; the organisation default is to wait). Until then, however many pull requests merge, nothing waits for anyone and nothing is published. +- **The maintainer decides when it is ready** by removing the `Unreleased.` line in a pull request, which also sets the version markers to the version being released (`scripts/release-markers.sh prepare`). That push to `main` waits for approval in the repository's `wordpress-org` environment; only its required reviewers can approve, and approving publishes (a repository's policy can set a kind of bump to `auto`, published without waiting, or `off`, never published; the organisation default is to wait). Until then, however many pull requests merge, nothing waits for anyone and nothing is published. - **Outside contributors** need nothing more than the pull request: your change ships in the next version with its bullet in the changelog. You cannot approve a release, and you do not need to. ## AI tools diff --git a/README.md b/README.md index 5bca943..3b9833f 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,10 @@ workflow* runs everything by hand, and its failure is in the run alone. `= X.Y.Z =` (or `= Unreleased =`) and its first line is `Unreleased.` while the version is not ready. Every pull request that changes what a user sees adds its bullet under that line; the pull request that removes - the line is the release decision, and the next push to `main` waits for - the environment's reviewers. + the line is the release decision, sets the three version markers to the + version being released (`scripts/release-markers.sh prepare`), and the + next push to `main` waits for the environment's reviewers. Between + releases the markers say the last version released. ## Migrating a repository from `v1` to `v2` @@ -153,14 +155,15 @@ Call them pinned to `@v2`; a breaking change ships as `v2`. A stack suffix | [`review-reply.yml`](.github/workflows/review-reply.yml) | Answers `@dilux-bot` mentions from members and collaborators, with the strong model. | `profile`, `central-ref` | | [`auto-merge.yml`](.github/workflows/auto-merge.yml) | Turns GitHub's auto-merge on or off from the review's outputs and commits the description verbatim. `pull-request-edited.yml` runs it on `edited` too. | the five review outputs | | [`scripts/release-ready.sh`](scripts/release-ready.sh) | Not a workflow: whether a readme's newest changelog entry is ready (exit 0) or held by a first line `Unreleased.` (exit 1); `--test` for its own tests. The release job's hold. | the readme | +| [`scripts/release-markers.sh`](scripts/release-markers.sh) | Not a workflow: `check` holds the three version markers to a real version (the last one released, or the next one in the pull request that releases it and on `main` after it merged; the checks' readme job and the release job run it); `prepare` turns a tree into its release pull request (removes the `Unreleased.` line, stamps the markers); `--test` for its own tests. | `check `, `prepare []` | | [`scripts/stamp-version.sh`](scripts/stamp-version.sh) | Not a workflow: stamps a plugin tree with a version (the `Version:` header, the constant, `Stable tag:`, a `= Unreleased =` heading; with a build, a `Build:` header line) and fails when a marker did not take it. The release and the development build stamp with it; `--test` for its own tests. | ` [] []` | | [`scripts/next-version.py`](scripts/next-version.py) | Not a workflow: the next version of a repository from the `type:*` labels of the pull requests merged since the last `X.Y.Z` tag (a `version:major|minor|patch` label a person sets wins): breaking → major, feat → minor, fix or perf → patch, anything else nothing to release. Also the development version, `-dev.`, N the commits since the tag. `--json` for machines, `--test` for its own tests. | `--repo`, `--base`, `--tag-prefix` | -| [`plugin-checks-wp.yml`](.github/workflows/plugin-checks-wp.yml) | Fast gates for a WordPress plugin: syntax and unit tests on every PHP from the minimum to the latest, PHPCS, PHPStan, Psalm taint, i18n, Plugin Check on the shipped tree, readme and versions. Needs the composer scripts `test:unit`, `lint`, `stan`, `psalm:taint`, a `.distignore` and a `readme.txt`. | `slug`, `main-file`, `version-constant`, `php-versions` | +| [`plugin-checks-wp.yml`](.github/workflows/plugin-checks-wp.yml) | Fast gates for a WordPress plugin: syntax and unit tests on every PHP from the minimum to the latest, PHPCS, PHPStan, Psalm taint, i18n, Plugin Check on the shipped tree, readme and versions (the markers against what was released: `scripts/release-markers.sh`). Needs the composer scripts `test:unit`, `lint`, `stan`, `psalm:taint`, a `.distignore` and a `readme.txt`. | `slug`, `main-file`, `version-constant`, `php-versions` | | [`plugin-tests-wp.yml`](.github/workflows/plugin-tests-wp.yml) | Slow suites on wp-env: PHPUnit integration (multisite) and Playwright E2E. Needs `.wp-env.json`, `phpunit-integration.xml` and a Playwright config that writes to `build/e2e-results`. | `integration`, `multisite`, `e2e` | | [`weekly-failure.yml`](.github/workflows/weekly-failure.yml) | When the scheduled full run fails: opens one `ci:weekly` issue with the run, or adds the run to the one already open. | none | | [`issue-triage.yml`](.github/workflows/issue-triage.yml) | When an issue opens: classifies it with the roadmap and the docs (bug to reproduce, needs info, by design, pro feature, enhancement, question, duplicate, security), applies the label and posts one reply; never closes. A report with steps made on an older version (an older release, or a development build older than the current pre-release) is still a bug to reproduce: the reproduction runs on the current code and says whether it is still there, instead of asking the reporter to update; the reply names the current version. On a schedule, closes `needs-info` issues nobody answered. Light model. | every repository (`dev-tag`) | | [`issue-repro.yml`](.github/workflows/issue-repro.yml) | When an issue gets `bug:unconfirmed` (or `repro:again`): Claude writes one unit test that fails if the bug exists (no shell), a second job with no secrets and a read-only token runs it, a third with the bot token pushes the file the first job produced (hash-checked) and reports. Fails: `bug:confirmed` plus a draft PR with the test. Passes: `could-not-reproduce` and a question to the reporter; when the report named an older version, the reply says the current code (the development build, linked) may already have the fix and asks to try it. Both verdicts say what they ran on. At most 5 a day. | repositories with unit tests (`dev-tag`) | -| [`plugin-release-wp.yml`](.github/workflows/plugin-release-wp.yml) | The release as a deployment. On a push to `main`: computes the next version from the `type:*` labels of what merged (`scripts/next-version.py`); nothing pending, the policy's `release.: off`, or a readme whose newest changelog entry still starts with the line `Unreleased.` (the version is not ready), ends there, green, and the summary says why. Otherwise waits for the reviewers of the repository's environment (the summary shows the version, the bump and the pull requests), then stamps the three version markers in the checkout, validates them and the changelog (`= X.Y.Z =` or `= Unreleased =` renamed), deploys to wordpress.org SVN, creates the tag with the release App's token and the GitHub release with the changelog and what was merged, grouped by type. On a tag `X.Y.Z` pushed by hand: the same, and the tag must be the version the labels say is next and the readme must be ready. Every push to `main` also publishes a development build, the shipped tree stamped `-dev.` with a `Build: ` header, as the one **Development build** pre-release on the moving tag `dev-tag` (default `dev`), replaced each time: fixed asset URL `…/releases/download/dev/.zip`, no history (the commit rebuilds any build), "Latest" stays the last `X.Y.Z`. `dry-run` rehearses everything but the SVN commit, the tag and the release (the notes go to the summary). The caller passes `secrets: inherit` and runs only on `main` and `X.Y.Z` tags. Outputs `version` and `dev`. | `slug`, `main-file`, `version-constant`, `dry-run`, `environment`, `auto-environment`, `central-ref`, `dev-tag` | +| [`plugin-release-wp.yml`](.github/workflows/plugin-release-wp.yml) | The release as a deployment. On a push to `main`: computes the next version from the `type:*` labels of what merged (`scripts/next-version.py`); nothing pending, the policy's `release.: off`, or a readme whose newest changelog entry still starts with the line `Unreleased.` (the version is not ready), ends there, green, and the summary says why. Otherwise waits for the reviewers of the repository's environment (the summary shows the version, the bump and the pull requests), then checks that the three version markers already say the version (the release pull request set them) and validates the changelog (`= X.Y.Z =`), deploys to wordpress.org SVN, creates the tag with the release App's token and the GitHub release with the changelog and what was merged, grouped by type. On a tag `X.Y.Z` pushed by hand: the same, and the tag must be the version the labels say is next and the readme must be ready. Every push to `main` also publishes a development build, the shipped tree stamped `-dev.` with a `Build: ` header, as the one **Development build** pre-release on the moving tag `dev-tag` (default `dev`), replaced each time: fixed asset URL `…/releases/download/dev/.zip`, no history (the commit rebuilds any build), "Latest" stays the last `X.Y.Z`. `dry-run` rehearses everything but the SVN commit, the tag and the release (the notes go to the summary). The caller passes `secrets: inherit` and runs only on `main` and `X.Y.Z` tags. Outputs `version` and `dev`. | `slug`, `main-file`, `version-constant`, `dry-run`, `environment`, `auto-environment`, `central-ref`, `dev-tag` | Only here: [`review-learnings.yml`](.github/workflows/review-learnings.yml) (every Monday: finds with one search the pull requests the bot reviewed diff --git a/review-profiles/general.md b/review-profiles/general.md index a05adf1..88a3157 100644 --- a/review-profiles/general.md +++ b/review-profiles/general.md @@ -77,6 +77,15 @@ with one new behaviour is a `feat`; anything breaking is `breaking`. The type sets the `type:*` label and corrects the title's type token, and the next version is computed from those labels, so read the diff, not the words. +Size is not breakage. A large feature that keeps everything working is a +`feat`, however big; `breaking` is only for a change someone has to act on. +The organisation numbers a big new capability (a new provider, a flow the +product did not have) as a major, but that is the maintainer's call, made +with the `version:major` label (CONTRIBUTING.md, "How a change becomes a +version"): when a `feat` looks like one, say so in one line of the summary +("looks like a new capability: `version:major` if the maintainer agrees"), +and never set that label or change the type for it. + ## Whether the description matches `description_matches` is true only when the description's "What changes" and diff --git a/scripts/release-markers.sh b/scripts/release-markers.sh new file mode 100644 index 0000000..f7ff131 --- /dev/null +++ b/scripts/release-markers.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# The version a plugin's files say, against the version that was released. +# +# The three markers (the `Version:` header of the main file, its PHP +# constant, `Stable tag:` in readme.txt) always name a real version on the +# default branch: the last one released, or, from the moment the pull +# request that releases the next one merges, that next one. The release +# pull request (the one that removes the `Unreleased.` line) sets them; no +# other pull request touches them. +# +# release-markers.sh check +# +# is the last X.Y.Z released, the version the labels give and +# whether anything asks for it (true/false), all three from +# scripts/next-version.py. The markers must equal when something is +# pending and the readme is ready (a release pull request, or main after it +# merged and before the release job ran), and otherwise. When +# is the target the newest changelog entry must be headed `= =`. +# With no release yet ( is 0.0.0) there is nothing to compare with and +# the check passes. Prints what it compared; exits 1 on a mismatch, with an +# error annotation saying what to set. +# +# release-markers.sh prepare [] +# +# Turns a tree into its release pull request: removes the `Unreleased.` line +# of the newest changelog entry, then stamps the three markers and a +# `= Unreleased =` heading to (scripts/stamp-version.sh). Refuses a +# readme that is not held (nothing to release) and fails when the result is +# not ready. +# +# release-markers.sh --test +# +# Runs its own tests. +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +marker() { # + case "$4" in + version) grep -oP '^\s*\*\s*Version:\s*\K\S+' "$1/$2" || true ;; + stable) grep -oP '^Stable tag:\s*\K\S+' "$1/readme.txt" || true ;; + constant) [ -n "$3" ] && { grep -oP "^define\(\s*'${3}',\s*'\K[^']+" "$1/$2" || true; } ;; + esac +} + +check() { + local dir=$1 main=$2 constant=$3 last=$4 next=$5 pending=$6 held=false rc heading expected why fail=0 v + if [ "$last" = "0.0.0" ]; then + echo "No release yet: the markers have nothing to match." + return 0 + fi + rc=0; heading=$(bash "$here/release-ready.sh" "$dir/readme.txt") || rc=$? + case "$rc" in 0|2) ;; 1) held=true ;; *) echo "::error::release-ready.sh failed ($rc)." >&2; return 1 ;; esac + heading=${heading%% *} + if [ "$pending" = true ] && [ "$held" = false ]; then + expected=$next; why="this releases $next (the readme is ready and the labels give $next)" + else + expected=$last; why="$last is the last release and nothing is being released" + fi + for which in version stable constant; do + [ "$which" = constant ] && [ -z "$constant" ] && continue + v=$(marker "$dir" "$main" "$constant" "$which") + if [ "$v" != "$expected" ]; then + case "$which" in + version) echo "::error file=$main::The Version header says '${v:-nothing}' but $why: it must say $expected." >&2 ;; + stable) echo "::error file=readme.txt::Stable tag says '${v:-nothing}' but $why: it must say $expected." >&2 ;; + constant) echo "::error file=$main::$constant says '${v:-nothing}' but $why: it must say $expected." >&2 ;; + esac + fail=1 + fi + done + if [ "$expected" = "$next" ] && [ "$next" != "$last" ] && [ "$heading" != "= $next =" ]; then + echo "::error file=readme.txt::The newest changelog entry is headed '${heading:-nothing}' but this releases $next: head it '= $next ='." >&2 + fail=1 + fi + if [ "$fail" -ne 0 ]; then + if [ "$expected" = "$next" ]; then + echo "The release pull request sets them: scripts/release-markers.sh prepare $next [], from a checkout of DiluxOne/.github." >&2 + else + echo "Set the three markers to $expected in a pull request of their own: main records the version it was released as." >&2 + fi + return 1 + fi + echo "Markers OK: $expected ($why)." +} + +prepare() { + local dir=$1 version=$2 main=$3 constant=${4:-} rc=0 + [ -f "$dir/readme.txt" ] || { echo "::error::$dir/readme.txt does not exist." >&2; return 1; } + sed -i 's/\r$//' "$dir/readme.txt" + bash "$here/release-ready.sh" "$dir/readme.txt" >/dev/null || rc=$? + [ "$rc" -eq 1 ] || { echo "::error file=readme.txt::The newest changelog entry does not start with 'Unreleased.': there is no held version to release." >&2; return 1; } + # The first non-blank line of the newest entry is the hold: it goes, and + # only it (a bullet that reads "Unreleased." further down stays). + awk ' + /^== Changelog ==$/ { c = 1; print; next } + c && !done && /^= .* =$/ { h = 1; print; next } + h && !done && NF { done = 1; if ($0 ~ /^Unreleased\.?$/) next } + { print } + ' "$dir/readme.txt" > "$dir/readme.txt.tmp" && mv "$dir/readme.txt.tmp" "$dir/readme.txt" + bash "$here/stamp-version.sh" "$dir" "$version" "$main" "$constant" + rc=0; bash "$here/release-ready.sh" "$dir/readme.txt" >/dev/null || rc=$? + [ "$rc" -eq 0 ] || { echo "::error file=readme.txt::The readme is still not ready after removing the hold." >&2; return 1; } + echo "Prepared $version: the hold removed, the markers stamped." +} + +if [ "${1:-}" = "--test" ]; then + fail=0 + t=$(mktemp -d); trap 'rm -rf "$t"' EXIT + tree() { # + rm -rf "$t/p"; mkdir -p "$t/p" + printf ' "$t/p/my-plugin.php" + printf '=== My Plugin ===\nStable tag: %s\n\n== Changelog ==\n\n= %s =\n%s\n\n* A bullet.\n\n= 1.0.0 =\nFirst.\n' "$1" "$2" "$3" > "$t/p/readme.txt" + } + ok() { if "${@:2}" >/dev/null 2>&1; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; } + fails() { if "${@:2}" >/dev/null 2>&1; then echo "FAIL $1"; fail=1; else echo "ok $1"; fi; } + # shellcheck disable=SC2329 # called through ok/fails + c() { check "$t/p" my-plugin.php MY_VERSION "$@"; } + + tree 2.0.0 2.1.0 "Unreleased." + ok "held, markers at the last release" c 2.0.0 2.1.0 true + ok "held, nothing pending" c 2.0.0 2.0.1 false + tree 1.0.0 2.1.0 "Unreleased." + fails "held, markers behind the last release" c 2.0.0 2.1.0 true + tree 2.1.0 2.1.0 "Unreleased." + fails "held, markers already at the next one" c 2.0.0 2.1.0 true + tree 2.1.0 2.1.0 "* Ready." + ok "ready and pending: markers at the next one" c 2.0.0 2.1.0 true + tree 2.0.0 2.1.0 "* Ready." + fails "ready and pending: markers left at the last" c 2.0.0 2.1.0 true + tree 2.1.0 2.2.0 "* Ready." + fails "ready and pending: heading is another version" c 2.0.0 2.1.0 true + tree 2.0.0 2.0.0 "* Shipped." + ok "after the release: markers at it" c 2.0.0 2.0.1 false + tree 1.0.0 2.0.0 "* Shipped." + fails "after the release: markers never moved" c 2.0.0 2.0.1 false + tree 2.0.0 2.0.0 "* Shipped."; sed -i "s/'MY_VERSION', '2.0.0'/'MY_VERSION', '1.0.0'/" "$t/p/my-plugin.php" + fails "the constant alone is behind" c 2.0.0 2.0.1 false + ok "no constant: only header and Stable tag" check "$t/p" my-plugin.php "" 2.0.0 2.0.1 false + tree 0.9.0 1.0.0 "Unreleased." + ok "no release yet: nothing to compare" c 0.0.0 0.1.0 true + + tree 2.0.0 2.1.0 "Unreleased." + ok "prepare: a held readme" prepare "$t/p" 2.1.0 my-plugin.php MY_VERSION + ok "prepare: the hold is gone" bash -c "! grep -qx 'Unreleased.' '$t/p/readme.txt'" + ok "prepare: the bullet stays" grep -qx '\* A bullet.' "$t/p/readme.txt" + ok "prepare: the result passes the check" c 2.0.0 2.1.0 true + tree 2.0.0 Unreleased "Unreleased." + ok "prepare: = Unreleased = heading" prepare "$t/p" 2.1.0 my-plugin.php MY_VERSION + ok "prepare: heading renamed" grep -qx '= 2.1.0 =' "$t/p/readme.txt" + tree 2.0.0 2.1.0 "* Ready." + fails "prepare: refuses a readme that is not held" prepare "$t/p" 2.1.0 my-plugin.php MY_VERSION + tree 2.0.0 2.1.0 "Unreleased."; sed -i 's/$/\r/' "$t/p/readme.txt" + ok "prepare: CRLF readme" prepare "$t/p" 2.1.0 my-plugin.php MY_VERSION + + [ "$fail" -eq 0 ] && echo "all tests passed" + exit "$fail" +fi + +case "${1:-}" in + check) shift; [ $# -eq 6 ] || { echo "usage: release-markers.sh check " >&2; exit 64; }; check "$@" ;; + prepare) shift; [ $# -ge 3 ] || { echo "usage: release-markers.sh prepare []" >&2; exit 64; }; prepare "$@" ;; + *) echo "usage: release-markers.sh check|prepare … | --test" >&2; exit 64 ;; +esac From 61b3c3cab6ad9789add801016d6d36d15f9db5b8 Mon Sep 17 00:00:00 2001 From: Pablo Ariel Di Loreto Date: Mon, 28 Sep 2026 01:54:16 +0000 Subject: [PATCH 2/3] fix(release): a fix after a release keeps the markers; a release PR counts its own labels The review found that after a release, a fix merged before anyone opened the next changelog entry made every later pull request fail: the readme is ready (its newest entry is the last release's), something is pending, so the check wanted the next version, and pointed to prepare, which refuses a readme that is not held. The newest entry being the last release's now means nothing is being released yet, and the markers stay at it; a readme that is ready for another version gets a hint that fits (set markers and heading, or settle it with a label, or put the hold back). The check of an open pull request now counts its own labels as if it had merged (next-version.py --with-pull), so a release pull request that carries version:major is checked against the major it will release instead of passing and leaving main refused by the release job. The release workflow's header and its changelog error no longer offer a `= Unreleased =` heading at release time, which the markers check now rejects; the release caller template and the release-automation plan say what the release does now. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plugin-checks-wp.yml | 7 ++++++- .github/workflows/plugin-release-wp.yml | 6 +++--- docs/plans/release-automation.md | 2 ++ scripts/next-version.py | 10 +++++++++- scripts/release-markers.sh | 17 ++++++++++++----- workflow-templates/release-wp.yml | 5 ++++- 6 files changed, 36 insertions(+), 11 deletions(-) diff --git a/.github/workflows/plugin-checks-wp.yml b/.github/workflows/plugin-checks-wp.yml index 6533b11..f7c08d6 100644 --- a/.github/workflows/plugin-checks-wp.yml +++ b/.github/workflows/plugin-checks-wp.yml @@ -308,9 +308,14 @@ jobs: MAIN: ${{ inputs.main-file != '' && inputs.main-file || format('{0}.php', inputs.slug) }} CONSTANT: ${{ inputs.version-constant }} BASE: ${{ github.event.repository.default_branch }} + PULL: ${{ github.event.pull_request.number }} + # In a pull request its own labels count as if it had merged, so the + # release pull request is checked against the version it will + # release, its own version:* label included (set it before the last + # push: a label alone does not re-run the checks). run: | set -euo pipefail - next=$(python3 .dx-central/scripts/next-version.py --repo "$GITHUB_REPOSITORY" --base "$BASE" --json) + next=$(python3 .dx-central/scripts/next-version.py --repo "$GITHUB_REPOSITORY" --base "$BASE" ${PULL:+--with-pull "$PULL"} --json) bash .dx-central/scripts/release-markers.sh check . "$MAIN" "$CONSTANT" \ "$(jq -r .last <<<"$next")" "$(jq -r .next <<<"$next")" "$(jq -r .pending <<<"$next")" diff --git a/.github/workflows/plugin-release-wp.yml b/.github/workflows/plugin-release-wp.yml index a1cbf1b..db931d9 100644 --- a/.github/workflows/plugin-release-wp.yml +++ b/.github/workflows/plugin-release-wp.yml @@ -33,8 +33,8 @@ name: WordPress plugin release # created itself arrives with its release already there, so that run stops. # # The readme's newest changelog entry names the version: `= X.Y.Z =` equal to -# the computed one, or `= Unreleased =`, which the job renames in the build. A -# different number is a disagreement between the roadmap and the labels, and +# the computed one (while it is held it may be headed `= Unreleased =`; the +# release pull request renames it with the markers). A different number is a disagreement between the roadmap and the labels, and # the job refuses until a person settles it with a version:* label or the # readme. The same entry says whether the version is ready: while its first # line is `Unreleased.` (scripts/release-ready.sh), the version job ends green with "Not ready" @@ -450,7 +450,7 @@ jobs: fi newest=$(grep -m1 -oP '^= \K[0-9]+\.[0-9]+\.[0-9]+(?= =$)' readme.txt || true) if [ "$newest" != "$VERSION" ]; then - echo "::error file=readme.txt::The newest changelog entry is '= ${newest:-none} =' but the labels of what merged give $VERSION. Either the readme announces a version the labels do not reach (add version:major, version:minor or version:patch to a merged pull request) or the labels went further than the readme (write the entry, or head it '= Unreleased ='). Nothing reached wordpress.org." + echo "::error file=readme.txt::The newest changelog entry is '= ${newest:-none} =' but the labels of what merged give $VERSION. Either the readme announces a version the labels do not reach (add version:major, version:minor or version:patch to a merged pull request) or the labels went further than the readme (head the entry and set the markers to $VERSION in a pull request). Nothing reached wordpress.org." exit 1 fi awk -v head="= ${VERSION} =" '$0 == head { found = 1; next } found && /^=/ { exit } found { print }' readme.txt > "$RUNNER_TEMP/notes.md" diff --git a/docs/plans/release-automation.md b/docs/plans/release-automation.md index fa43764..4583019 100644 --- a/docs/plans/release-automation.md +++ b/docs/plans/release-automation.md @@ -2,6 +2,8 @@ Status: decided 2026-09-26, reviewed for security, cost and against the tools the market uses. The release is approved as a **deployment** (the job on `main` builds, waits for approval in the `wordpress-org` environment, then tags and publishes), not as a release pull request. Shipped so far: the security fixes (DiluxOne/.github#3), the type of a change by the review (#4), nothing runs twice, `next-version.py`. Pending: the stamped development builds and the release job itself. +Amended 2026-09-28 (DiluxOne/.github#12): the version markers on `main` no longer stay at the last released version while the release job stamps only its build. The pull request that removes `Unreleased.` (the release decision, already a pull request) also sets the markers to the version it releases, the checks hold every pull request to them and the release job refuses a commit that names another version; still no bot pull request and no bump-back commit. Where this plan says the markers stay at the last release or that nothing about the version is stored on `main`, read it that way. + ## Goals, in the maintainer's words 1. The type of every change (feature, fix, docs, breaking) is decided by the AI from the diff, not by whoever typed the title, and is visible as a label. diff --git a/scripts/next-version.py b/scripts/next-version.py index b136789..ca43577 100755 --- a/scripts/next-version.py +++ b/scripts/next-version.py @@ -18,7 +18,11 @@ is pending the coming version is the next patch, because a release, when it comes, is at least that; `pending` says which case it is. - next-version.py [--repo OWNER/REPO] [--base main] [--tag-prefix v] [--exclude-tag X.Y.Z] [--json] +With --with-pull N the labels of pull request N count as if it had merged: +the checks of an open pull request (the one that releases, above all) see +the version it will release once it merges, its own version:* label included. + + next-version.py [--repo OWNER/REPO] [--base main] [--tag-prefix v] [--exclude-tag X.Y.Z] [--with-pull N] [--json] next-version.py --test Needs `gh` logged in (or GH_TOKEN). Exit 0 with the answer, 2 on a bad @@ -197,6 +201,7 @@ def main(): ap.add_argument("--base", default="main") ap.add_argument("--tag-prefix", default="", help="what precedes X.Y.Z in the release tags (this repository: v)") ap.add_argument("--exclude-tag", default=None, help="a tag to ignore when looking for the last release: the one being released") + ap.add_argument("--with-pull", type=int, default=None, help="an open pull request whose labels count as if it had merged") ap.add_argument("--json", action="store_true", help="print the answer as JSON") ap.add_argument("--test", action="store_true", help="run the self-tests and exit") a = ap.parse_args() @@ -204,6 +209,9 @@ def main(): self_test() repo = a.repo or gh("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner").strip() last, pulls, commits_since = read_github(repo, a.base, a.tag_prefix, a.exclude_tag) + if a.with_pull and a.with_pull not in {n for n, _ in pulls}: + labels = json.loads(gh("api", "repos/%s/pulls/%d" % (repo, a.with_pull), "--jq", "[.labels[].name]")) + pulls = sorted(pulls + [(a.with_pull, labels)]) try: d = decide(last, pulls, commits_since) except ValueError as e: diff --git a/scripts/release-markers.sh b/scripts/release-markers.sh index f7ff131..178d0c4 100644 --- a/scripts/release-markers.sh +++ b/scripts/release-markers.sh @@ -13,9 +13,11 @@ # is the last X.Y.Z released, the version the labels give and # whether anything asks for it (true/false), all three from # scripts/next-version.py. The markers must equal when something is -# pending and the readme is ready (a release pull request, or main after it -# merged and before the release job ran), and otherwise. When -# is the target the newest changelog entry must be headed `= =`. +# pending, the readme is ready and its newest entry is not the one of +# (a release pull request, or main after it merged and before the release +# job ran), and otherwise: a fix merged after a release, before +# anyone opened the next entry, releases nothing yet. When is the +# target the newest changelog entry must be headed `= =`. # With no release yet ( is 0.0.0) there is nothing to compare with and # the check passes. Prints what it compared; exits 1 on a mismatch, with an # error annotation saying what to set. @@ -52,7 +54,7 @@ check() { rc=0; heading=$(bash "$here/release-ready.sh" "$dir/readme.txt") || rc=$? case "$rc" in 0|2) ;; 1) held=true ;; *) echo "::error::release-ready.sh failed ($rc)." >&2; return 1 ;; esac heading=${heading%% *} - if [ "$pending" = true ] && [ "$held" = false ]; then + if [ "$pending" = true ] && [ "$held" = false ] && [ "$heading" != "= $last =" ]; then expected=$next; why="this releases $next (the readme is ready and the labels give $next)" else expected=$last; why="$last is the last release and nothing is being released" @@ -74,8 +76,10 @@ check() { fail=1 fi if [ "$fail" -ne 0 ]; then - if [ "$expected" = "$next" ]; then + if [ "$expected" = "$next" ] && [ "$held" = true ]; then echo "The release pull request sets them: scripts/release-markers.sh prepare $next [], from a checkout of DiluxOne/.github." >&2 + elif [ "$expected" = "$next" ]; then + echo "The readme is ready, so this releases $next: set the three markers and the newest changelog heading to $next, or, if $next is not the version meant, settle it with a version:* label or put the 'Unreleased.' line back." >&2 else echo "Set the three markers to $expected in a pull request of their own: main records the version it was released as." >&2 fi @@ -132,6 +136,9 @@ if [ "${1:-}" = "--test" ]; then fails "ready and pending: heading is another version" c 2.0.0 2.1.0 true tree 2.0.0 2.0.0 "* Shipped." ok "after the release: markers at it" c 2.0.0 2.0.1 false + ok "a fix merged after the release, no new entry" c 2.0.0 2.0.1 true + tree 2.0.1 2.0.0 "* Shipped." + fails "that fix does not move the markers" c 2.0.0 2.0.1 true tree 1.0.0 2.0.0 "* Shipped." fails "after the release: markers never moved" c 2.0.0 2.0.1 false tree 2.0.0 2.0.0 "* Shipped."; sed -i "s/'MY_VERSION', '2.0.0'/'MY_VERSION', '1.0.0'/" "$t/p/my-plugin.php" diff --git a/workflow-templates/release-wp.yml b/workflow-templates/release-wp.yml index d5f3623..64a828e 100644 --- a/workflow-templates/release-wp.yml +++ b/workflow-templates/release-wp.yml @@ -4,7 +4,10 @@ name: Release # DiluxOne/.github, as a deployment: on every push to main the workflow # computes the next version from the type:* labels of what merged; when # something is pending, it waits for approval in the `wordpress-org` -# environment, then stamps, deploys, tags and creates the release. A tag +# environment, then checks that the version markers say it (the release pull +# request, the one that removes the `Unreleased.` line, sets them with +# scripts/release-markers.sh prepare), deploys, tags and creates the +# release. A tag # X.Y.Z an administrator pushes by hand goes through the same job, and must # be the version the labels say is next. While the readme's newest changelog # entry starts with the line `Unreleased.` nothing waits for approval: the From e7b1ef3ce593da8856b1ecaa24978ce109b86823 Mon Sep 17 00:00:00 2001 From: Pablo Ariel Di Loreto Date: Mon, 28 Sep 2026 01:56:42 +0000 Subject: [PATCH 3/3] test(release): next-version.py tests --with-pull; the checks say why a release PR carries no bump The review asked for a self-test of the new --with-pull path and for the checks' comment to say that the review's type:* label, set after the job ran, is not seen either. The fold is now a function with its own test (an open version:major gives the major, a chore adds nothing, a pull request already merged counts once), and the comment says why the release pull request holds nothing but the hold and the markers, and that the release job still refuses anything that slips through. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plugin-checks-wp.yml | 8 ++++++-- scripts/next-version.py | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/plugin-checks-wp.yml b/.github/workflows/plugin-checks-wp.yml index f7c08d6..8105167 100644 --- a/.github/workflows/plugin-checks-wp.yml +++ b/.github/workflows/plugin-checks-wp.yml @@ -311,8 +311,12 @@ jobs: PULL: ${{ github.event.pull_request.number }} # In a pull request its own labels count as if it had merged, so the # release pull request is checked against the version it will - # release, its own version:* label included (set it before the last - # push: a label alone does not re-run the checks). + # release, its own version:* label included. A label alone does not + # re-run the checks: set version:* before the last push. The review's + # type:* arrives after this job ran, which is why the release pull + # request contains nothing but the hold and the markers (a chore, + # no bump of its own); anything that slips through is still refused + # by the release job before SVN. run: | set -euo pipefail next=$(python3 .dx-central/scripts/next-version.py --repo "$GITHUB_REPOSITORY" --base "$BASE" ${PULL:+--with-pull "$PULL"} --json) diff --git a/scripts/next-version.py b/scripts/next-version.py index ca43577..29d818e 100755 --- a/scripts/next-version.py +++ b/scripts/next-version.py @@ -103,6 +103,14 @@ def decide(last, pulls, commits_since): } +def with_pull(pulls, number, labels): + """`pulls` with an open pull request's labels counted as if it had + merged; a pull request already among them counts once.""" + if number in {n for n, _ in pulls}: + return pulls + return sorted(pulls + [(number, labels)]) + + def gh(*args): out = subprocess.run(["gh", *args], capture_output=True, text=True) if out.returncode != 0: @@ -184,6 +192,12 @@ def test_decide(self): d = decide((0, 0, 0), [], 0) self.assertEqual((d["last"], d["next"], d["dev"]), ("0.0.0", "0.0.1", "0.0.1-dev.0")) + def test_with_pull(self): + merged = [(3, ["type:feat"])] + self.assertEqual(decide((2, 0, 0), with_pull(merged, 9, ["version:major"]), 4)["next"], "3.0.0") + self.assertEqual(decide((2, 0, 0), with_pull(merged, 9, ["type:chore"]), 4)["next"], "2.1.0") + self.assertEqual(with_pull(merged, 3, ["version:major"]), merged) + def test_monotonic(self): # A bigger bump only ever raises the coming version. last = (1, 4, 2) @@ -211,7 +225,7 @@ def main(): last, pulls, commits_since = read_github(repo, a.base, a.tag_prefix, a.exclude_tag) if a.with_pull and a.with_pull not in {n for n, _ in pulls}: labels = json.loads(gh("api", "repos/%s/pulls/%d" % (repo, a.with_pull), "--jq", "[.labels[].name]")) - pulls = sorted(pulls + [(a.with_pull, labels)]) + pulls = with_pull(pulls, a.with_pull, labels) try: d = decide(last, pulls, commits_since) except ValueError as e: