Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
142 changes: 65 additions & 77 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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 <version>` on a green `main`. Every manual
`versions:set`, hand-edited `<version>`, 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 (`<version>`):
Choosing the number (`X.Y.Z`):

| Change since last release | Bump | Example |
|---|---|---|
Expand All @@ -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
Expand All @@ -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-<ver>_<scala>` 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.
47 changes: 19 additions & 28 deletions docs/developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 5 additions & 13 deletions scripts/check_version_hygiene.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version> line in any pom.xml that already exists at the
# merge-base, nor the version field in python/pyproject.toml, added or
Expand All @@ -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 -------------------------------------------------

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading