diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 790951c..8da0c8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,10 @@ name: Release -# WP-22: tag-triggered publish to Maven Central + PyPI (docs/RELEASING.md). +# WP-22 v2: tag-triggered publish to Maven Central + PyPI (docs/RELEASING.md). +# +# Tag-stamped model: the tag is the ONLY version decision. The workflow +# stamps the POM and pyproject versions from the tag in the build workspace +# (never committed) — main's dev-marker versions never change. # # The trigger glob `v*.*.*` alone is NOT numeric — `v1.0.0-rc1` matches it, # and Central is immutable. Publication is therefore enforced twice: the @@ -33,6 +37,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 # the on-main guard needs origin/main history - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -46,16 +52,12 @@ jobs: VERSION="${GITHUB_REF_NAME#v}" echo "VERSION=$VERSION" >> "$GITHUB_ENV" - # Tag and published version are two views of one decision: the tagged - # commit's POM must already carry this exact version (prepare_release.sh - # wrote it). A mismatch fails the build instead of publishing wrong - # coordinates to an immutable repository. - - name: Guard — POM version must equal the tag + # Releases are cut from green main only: the tagged commit must be an + # ancestor of origin/main. (^{commit} peels annotated tags.) + - name: Guard — tagged commit must be on main run: | - POM_VERSION="$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)" - echo "tag=$VERSION pom=$POM_VERSION" - [ "$POM_VERSION" = "$VERSION" ] || { - echo "ERROR: POM version ($POM_VERSION) != tag version ($VERSION) — refusing to publish" >&2 + git merge-base --is-ancestor "${GITHUB_SHA}^{commit}" origin/main || { + echo "ERROR: tag $GITHUB_REF_NAME does not point at a commit on main — refusing to publish" >&2 exit 1 } @@ -81,12 +83,12 @@ jobs: XML # Tests are NOT re-run here: releases cut only from green main (CI - # already ran the full matrix on this commit). - - name: Build and deploy to Maven Central - run: > - mvn -B -Prelease clean deploy - -DskipTests - -Dgpg.passphrase="$GPG_PASSPHRASE" + # already ran the full matrix on this commit). The version stamp is + # workspace-only — main's dev-marker version is never committed. + - name: Stamp release version and deploy to Maven Central + run: | + mvn -B -q versions:set -DnewVersion="$VERSION" -DgenerateBackupPoms=false + mvn -B -Prelease clean deploy -DskipTests -Dgpg.passphrase="$GPG_PASSPHRASE" env: CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} @@ -121,6 +123,13 @@ jobs: with: python-version: '3.12' + - name: Stamp wheel version from tag + run: | + VERSION="${GITHUB_REF_NAME#v}" + sed -i.bak -E 's/^version = "[^\"]*"/version = "'"$VERSION"'"/' python/pyproject.toml + rm -f python/pyproject.toml.bak + grep "^version" python/pyproject.toml + - name: Download bundle jars from the Maven job uses: actions/download-artifact@v4 with: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 898becf..7773ae0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,89 +1,59 @@ # Releasing spark-mutator -The runbook for cutting a release. If you remember only one rule, make it -**Rule 1** below. +Runbook for cutting a release. The model: **the tag is the only version +decision; the workflow stamps it at build time.** If you remember only one +rule, make it **Rule 1** below. -> **Status (WP-22):** the release tooling now exists — `scripts/prepare_release.sh` -> and `.github/workflows/release.yml` implement this document. This document -> remains the normative process the tooling implements. The human one-time -> prerequisites (§7) are needed before the first release regardless. +> **Status (WP-22 v2):** tag-stamped releases. The release workflow derives +> the published version from the pushed tag and stamps the POM + pyproject +> versions in the build workspace (never committed). The human one-time +> prerequisites (§6) are needed before the first release regardless. --- ## 1. Base Rule -**Do not edit POM or `pyproject.toml` versions by hand. Ever.** +**Never hand-edit POM or `pyproject.toml` versions. Ever.** -The only version-changing action in this repository is running -`scripts/prepare_release.sh ` on a green `main`. Every manual -`versions:set`, hand-edited ``, or ad-hoc `git tag` is a way to -publish the wrong coordinates to immutable repositories. +Versions change only inside the release workflow, derived from the pushed +tag. `main` stays at its dev-marker version (`1.0.0-SNAPSHOT`, pyproject +`0.1.0`) forever — it is never published. The version-hygiene CI guard fails +any PR that touches version lines (escape hatch: a `[version-bump]` commit +subject for legitimate dependency upgrades). --- ## 2. The version model (what lives where) -| Place | Version | Who changes it | +| Place | Version | Who sets it | |---|---|---| -| `main` (day to day) | `X.Y.Z-SNAPSHOT` | Nobody. Frozen between releases. | -| The **release commit** | exact `X.Y.Z` | `prepare_release.sh`, once per release | -| The **tag** `vX.Y.Z` | points at the release commit | You, right after running the script | -| Maven Central / PyPI | `X.Y.Z` | The workflow, derived **from the tag** | +| `main` (day to day) | dev marker: `1.0.0-SNAPSHOT` / pyproject `0.1.0` | Nobody — frozen forever | +| The tag `vX.Y.Z` | exact `X.Y.Z` | You, at release time | +| Maven Central / PyPI | `X.Y.Z` | The workflow, stamped from the tag | -The published version is derived from the tag, and a guard verifies the -tagged commit's POM agrees — a tag/POM mismatch **fails the build** instead of -publishing wrong coordinates. Tag version and POM version are two views of one -decision, never independent inputs. +One decision (the tag), one derivation (the stamp). No release commits, no +post-release bumps, no POM/tag reconciliation — the tag cannot diverge from +the published version because the published version IS the tag. --- -## 3. The cycle - -``` -v1.0.0 published - │ - ▼ -main = 1.0.1-SNAPSHOT ← the script bumped it for you (post-release step) - │ - ├── start new development ← branch (or commit on main): NO version edits - ├── merge PRs ← main stays 1.0.1-SNAPSHOT - │ - ▼ "I'm ready to release" -prepare_release.sh 1.0.1 ← the ONLY time versions change (release commit) -git tag v1.0.1 && push ← publish; script bumps main to 1.0.2-SNAPSHOT - │ - ▼ -repeat -``` - -- **Starting new development after a release:** touch nothing — the - post-release bump already set the next SNAPSHOT. -- **Long-lived feature branch** open during a release: merge as usual - afterwards. Feature branches never edit POMs, so the merge has no version - conflict; main's SNAPSHOT simply continues. +## 3. The ritual (copy-paste) ---- - -## 4. The ritual (copy-paste) - -```bash -git checkout main && git pull # release ALWAYS from green main -scripts/prepare_release.sh 1.0.1 # versions:set + pyproject sync + release commit - # + post-release SNAPSHOT bump (both committed) -git tag v1.0.1 -git push origin main v1.0.1 # tag push triggers publish -``` +1. Confirm CI is green on the `main` commit you want to release. +2. Create the tag on `main`: + - GitHub UI → Releases → Draft a new release → new tag `v1.0.0` → target + `main` → Publish; or + - `git tag v1.0.0 && git push origin v1.0.0`. +3. Watch the `Release` workflow: **guard → publish-maven → publish-pypi → + smoke-test**, all green before announcing anything. +4. First release only: approve the deployment in the Central Portal UI + (`autoPublish=false`); the smoke-test job polls Central for up to 30 + minutes so the review delay does not fail the run. -The script creates TWO commits: the release commit (`release: v1.0.1`, exact -version) and the post-release bump (`1.0.2-SNAPSHOT`). It prints the exact -tag+push commands; tagging and pushing stay human actions. +That is the whole release. No local commits, no branch, no PR — releases +never push to `main`. -Then watch the `release` workflow: **guard → publish-maven → publish-pypi → -smoke-test**, all green before announcing anything. The workflow derives the -version FROM the tag and a guard step fails the build on a tag/POM mismatch -instead of publishing wrong coordinates. - -Choosing the number (``): +Choosing the number (`X.Y.Z`): | Change since last release | Bump | Example | |---|---|---| @@ -97,40 +67,43 @@ must never exist. --- -## 5. What the tag triggers +## 4. What the tag triggers 1. **Guard** — `GITHUB_REF_NAME` must match `^v[0-9]+\.[0-9]+\.[0-9]+$`. Non-matching tags skip silently. (The glob `v*.*.*` alone is NOT numeric: `v1.0.0-rc1` matches it, and Central is immutable.) -2. **publish-maven** — signs with GPG and deploys the public surface to Maven - Central via the Central Portal token. -3. **publish-pypi** — rebuilds the wheel from the same run's jars, publishes - to PyPI via OIDC trusted publishing. -4. **smoke-test** — installs the wheel from PyPI and resolves the plugin + +2. **On-main guard** — the tagged commit must be an ancestor of `origin/main` + (releases are cut from green `main` only). +3. **publish-maven** — stamps the POM version from the tag in the workspace + (`mvn versions:set`, never committed), signs with GPG, deploys the public + surface to Maven Central via the Central Portal token. +4. **publish-pypi** — stamps the pyproject version, rebuilds the wheel from + the same run's jars, publishes to PyPI via OIDC trusted publishing. +5. **smoke-test** — installs the wheel from PyPI and resolves the plugin + bundle from Central by coordinate. First release only: the Maven job lands in the Central Portal for **manual review** (`autoPublish=false`, set in the root POM's `release` profile) — -approve it in the Portal UI. The `smoke-test` job polls Central for up to 30 -minutes so the review delay does not fail the run. Flip `autoPublish` to -`true` in a LATER change, never in the packet/change that first publishes. +approve it in the Portal UI. Flip `autoPublish` to `true` in a LATER change, +never in the change that first publishes. --- -## 6. Guard rails — things that would hurt +## 5. Guard rails — things that would hurt - **Central is immutable.** A bad release is fixed by a *new* version, never by re-publishing. Never move or delete a pushed `vX.Y.Z` tag. -- **Never release from a branch** or from a non-green main. +- **Never release from a non-green main.** The workflow's on-main guard + enforces the tag's commit is on `main`; greenness is your judgment call. - **Never publish pre-release tags** (`v1.0.0-rc1` is rejected by the guard). If pre-releases are ever wanted, that is a deliberate workflow change, not a tag-time improvisation. - **Secrets never enter the repo** — they live in GitHub repository settings - (see §7). + (see §6). --- -## 7. One-time human prerequisites (before the very first release) +## 6. One-time human prerequisites (before the very first release) 1. **Maven Central Portal** account with the verified namespace `io.github.wpunit13` (verification via a temporary verification key in the @@ -145,3 +118,18 @@ minutes so the review delay does not fail the run. Flip `autoPublish` to `spark-mutation-testing-maven-plugin`, and one `interceptor-bundle-spark-_` per supported combo. Everything else stays internal. + +--- + +## 7. Design note — why there are no release commits + +WP-22 v1 created a release commit (exact version) plus a post-release bump on +`main`, so the POM at the tagged commit equaled the tag. That required direct +pushes to `main` and fought branch protection; every release needed a local +script, a release branch, a PR, and a SHA-targeted tag. + +V2 dissolves the constraint those commits served: the tag is the single +version decision and the workflow stamps everything else in the build +workspace. `main`'s version is a dev marker that never changes, so the +version-hygiene guard reduces to a pure hand-edit tripwire, and the release +ritual is tag-only. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 628e748..9a191df 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -629,36 +629,27 @@ writes `catalog.json` and applied markers. The full procedure, guard rails, and failure modes live in [`RELEASING.md`](RELEASING.md) — that runbook is normative, this section is the -quick path. One rule to remember: **versions change only via -`scripts/prepare_release.sh` on green `main`** (never hand-edit POM or -`pyproject.toml` versions). - -```bash -git checkout main && git pull # release ALWAYS from green main -scripts/prepare_release.sh 1.0.1 # release commit + post-release SNAPSHOT bump -git tag v1.0.1 -git push origin main v1.0.1 # tag push triggers publish -git tag -d v1.0.1 && git push origin :refs/tags/v1.0.1 +quick path. One rule to remember: **versions change only in the release +workflow, stamped from the pushed tag** (never hand-edit POM or +`pyproject.toml` versions; `main`'s dev-marker version never changes). + +```text +1. Confirm CI green on the main commit to release +2. Tag it: GitHub UI → Releases → new tag v1.0.0 on main → Publish + (or: git tag v1.0.0 && git push origin v1.0.0) +3. Watch the Release workflow: guard → publish-maven → publish-pypi → smoke-test +4. First release only: approve the deployment in the Central Portal UI ``` -What the tag push triggers (`.github/workflows/release.yml`): - -1. **guard** — strict numeric regex on the tag (a loose `v*.*.*` glob alone - would admit `v1.0.0-rc1`); non-matching tags skip silently. -2. **publish-maven** — verifies POM version == tag version, imports the GPG - key, then `mvn -Prelease clean deploy`: sources + javadoc/scaladoc jars, - GPG signing, Central Portal upload via `central-publishing-maven-plugin` - (server id `central`; only the public surface — internal shims/layers are - `excludeArtifacts`). -3. **publish-pypi** — bundles the SAME run's jars into the wheel, - `twine check`, publishes via OIDC trusted publishing (environment - `release`; no API token). -4. **smoke-test** — installs the wheel from PyPI and asserts the bundled - jars exist; resolves the plugin + a bundle from Central by coordinate - (with polling — the first release sits in the Portal for manual review - because `autoPublish=false`). - -First release only: approve the Maven deployment in the Central Portal UI. +The workflow derives the version FROM the tag and stamps it into the POM and +pyproject in the build workspace (never committed) — the published version +cannot diverge from the tag because it IS the tag. Releases never push to +`main`; no release commits, no version bumps. + +First release only: the Maven deployment sits in the Portal for manual review +(`autoPublish=false`) and Central's CDN sync lags a few minutes even after +approval — the smoke-test job polls instead of failing fast. + Central is immutable — a bad release is fixed by a NEW version, never by re-publishing or moving a tag. diff --git a/scripts/check_version_hygiene.sh b/scripts/check_version_hygiene.sh index 1d57bab..314f462 100755 --- a/scripts/check_version_hygiene.sh +++ b/scripts/check_version_hygiene.sh @@ -4,14 +4,11 @@ set -euo pipefail # WP-19 version-hygiene merge guard (docs/RELEASING.md Rule 1). # # Runs as a PR-triggered CI step. Two checks, both fail with a message citing -# Rule 1 ("versions change only via scripts/prepare_release.sh on main"): +# Rule 1 ("versions change only in the release workflow, stamped from the +# tag"): # # a) STATE: the resolved project version on the PR head ends in -SNAPSHOT. -# Non-SNAPSHOT versions exist only in script-created release commits, -# which never go through PRs — so any PR head that is not a SNAPSHOT is a -# hand-edit. (Only the Maven project version is checked: the Python -# wheel's day-to-day version is a plain PEP 440 version and is covered by -# the diff check below.) +# main is a permanent dev marker; nothing in a PR should change it. # # b) DIFF: no line in any pom.xml that already exists at the # merge-base, nor the version field in python/pyproject.toml, added or @@ -24,18 +21,13 @@ set -euo pipefail # subject contains [version-bump] are exempt (legitimate dependency # upgrades). # -# Deliberately NOT a state-equality check ("branch version == main version"): -# that fails legitimately whenever a release's post-release bump lands on main -# while a feature branch is open. -# -# Configuration: BASE_REF (default origin/main) names the PR's target branch. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "$REPO_ROOT" BASE_REF="${BASE_REF:-origin/main}" -RULE1_MSG="docs/RELEASING.md Rule 1: versions change only via scripts/prepare_release.sh on main" +RULE1_MSG="docs/RELEASING.md Rule 1: versions change only in the release workflow, stamped from the tag" # --- resolve the merge base ------------------------------------------------- @@ -78,7 +70,7 @@ case "$pom_version" in *) echo "ERROR: the PR head's project version is '$pom_version', not a -SNAPSHOT." >&2 echo "Rule 1: $RULE1_MSG" >&2 - echo "Non-SNAPSHOT versions exist only in script-created release commits, which never go through PRs — this looks like a hand edit." >&2 + echo "main's version is a permanent dev marker; PRs never change it." >&2 exit 1 ;; esac diff --git a/scripts/prepare_release.sh b/scripts/prepare_release.sh deleted file mode 100755 index 122a765..0000000 --- a/scripts/prepare_release.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# WP-22 release choreography (docs/RELEASING.md Rule 1: the ONLY place -# versions change). Creates the release commit at , then bumps the -# tree to the next patch SNAPSHOT and commits that too. Prints the exact -# tag+push commands; tagging/pushing stays a human action. - -usage() { echo "usage: $0 # e.g. 1.0.1" >&2; exit 1; } - -[ $# -eq 1 ] || usage -VERSION="$1" -echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "ERROR: version must be X.Y.Z (numeric), got '$VERSION'" >&2; usage; } - -# --- guard rails ------------------------------------------------------------ -branch="$(git rev-parse --abbrev-ref HEAD)" -[ "$branch" = "main" ] || { echo "ERROR: release only from main (on '$branch')" >&2; exit 1; } -[ -z "$(git status --porcelain)" ] || { echo "ERROR: dirty working tree — commit or stash first" >&2; exit 1; } -if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then - echo "ERROR: tag v$VERSION already exists — Central is immutable, never re-publish" >&2 - exit 1 -fi - -MAJOR="${VERSION%%.*}"; rest="${VERSION#*.}" -MINOR="${rest%%.*}"; PATCH="${rest#*.}" -NEXT="$MAJOR.$MINOR.$((PATCH + 1))" - -# --- release commit --------------------------------------------------------- -JAVA_HOME="${JAVA_HOME:-$(/usr/libexec/java_home -v 17)}" mvn -B -q versions:set -DnewVersion="$VERSION" -DgenerateBackupPoms=false - -sed -i.bak -E 's/^version = "[^"]*"/version = "'"$VERSION"'"/' python/pyproject.toml && rm -f python/pyproject.toml.bak -sed -i.bak 's/1\.0\.0-SNAPSHOT/'"$VERSION"'/g' docs/developer-guide.md && rm -f docs/developer-guide.md.bak - -git add pom.xml '**/pom.xml' python/pyproject.toml docs/developer-guide.md -git commit -m "release: v$VERSION" -RELEASE_SHA="$(git rev-parse HEAD)" - -# --- post-release bump ------------------------------------------------------ -mvn -B -q versions:set -DnewVersion="$NEXT-SNAPSHOT" -DgenerateBackupPoms=false -sed -i.bak -E 's/^version = "[^"]*"/version = "'"$NEXT"'"/' python/pyproject.toml && rm -f python/pyproject.toml.bak - -git add pom.xml '**/pom.xml' python/pyproject.toml -git commit -m "post-release bump: ${NEXT}-SNAPSHOT" - -echo -echo "Release commit: $RELEASE_SHA" -echo "Now run:" -echo " git tag v$VERSION $RELEASE_SHA" -echo " git push origin main v$VERSION"