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
132 changes: 96 additions & 36 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,85 @@ name: Release
# and Central is immutable. Publication is therefore enforced twice: the
# loose glob here for readability, and a strict regex guard below for
# correctness. Non-matching tags exit 0 (skip, not fail).
#
# workflow_dispatch is the recovery hatch (docs/RELEASING.md §4): a failed
# stage can be re-run independently after a fix landed on main — re-running
# the original run would replay the OLD workflow file. `pypi-only` rebuilds
# the jars from the tagged commit; `maven-only` must never target an
# already-published version (Central is immutable).

on:
push:
tags: ["v*.*.*"]
workflow_dispatch:
inputs:
version:
description: 'Release version X.Y.Z — must match an existing vX.Y.Z tag on main'
required: true
type: string
jobs:
description: 'Which stages to run'
type: choice
default: all
options: [all, maven-only, pypi-only, smoke-only]

jobs:
guard:
runs-on: ubuntu-latest
outputs:
ok: ${{ steps.check.outputs.ok }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Checkout (full history — the on-main check needs origin/main)
uses: actions/checkout@v4
with:
fetch-depth: 0

- id: check
env:
DISPATCH_VERSION: ${{ inputs.version }}
run: |
if echo "$GITHUB_REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ok=true" >> "$GITHUB_OUTPUT"
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$DISPATCH_VERSION"
else
echo "Tag $GITHUB_REF_NAME is not a numeric release tag (vX.Y.Z) — skipping."
echo "ok=false" >> "$GITHUB_OUTPUT"
# Strict numeric guard: the glob `v*.*.*` alone still matches
# `v1.0.0-rc1`. Non-matching tags skip silently (exit 0).
if ! echo "$GITHUB_REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag $GITHUB_REF_NAME is not a numeric release tag (vX.Y.Z) — skipping."
echo "ok=false" >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION="${GITHUB_REF_NAME#v}"
fi

echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || {
echo "ERROR: invalid version '$VERSION' (expected X.Y.Z)" >&2
exit 1
}
git rev-parse --verify --quiet "refs/tags/v$VERSION" >/dev/null || {
echo "ERROR: tag v$VERSION does not exist" >&2
exit 1
}
# Releases are cut from green main only: the tagged commit must be
# an ancestor of origin/main. (^{commit} peels annotated tags.)
git merge-base --is-ancestor "refs/tags/v$VERSION^{commit}" origin/main || {
echo "ERROR: tag v$VERSION does not point at a commit on main — refusing to publish" >&2
exit 1
}
echo "ok=true" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

publish-maven:
needs: guard
if: needs.guard.outputs.ok == 'true'
if: always() && needs.guard.outputs.ok == 'true' &&
(github.event_name == 'push' || inputs.jobs == 'all' || inputs.jobs == 'maven-only')
runs-on: ubuntu-latest
steps:
- name: Checkout
- name: Checkout the tagged commit
uses: actions/checkout@v4
with:
fetch-depth: 0 # the on-main guard needs origin/main history
ref: refs/tags/v${{ needs.guard.outputs.version }}

- name: Set up JDK 17
uses: actions/setup-java@v4
Expand All @@ -47,25 +97,13 @@ jobs:
distribution: 'temurin'
cache: 'maven'

- name: Derive version from tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"

# 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: |
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
}

- name: Import GPG signing key
run: |
echo "$GPG_PRIVATE_KEY" > "$RUNNER_TEMP/signing-key.asc"
gpg --batch --import "$RUNNER_TEMP/signing-key.asc"
rm -f "$RUNNER_TEMP/signing-key.asc"
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}

- name: Configure Maven credentials (server id `central`)
run: |
Expand All @@ -90,6 +128,7 @@ jobs:
mvn -B -q versions:set -DnewVersion="$VERSION" -DgenerateBackupPoms=false
mvn -B -Prelease clean deploy -DskipTests -Dgpg.passphrase="$GPG_PASSPHRASE"
env:
VERSION: ${{ needs.guard.outputs.version }}
CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
Expand All @@ -106,7 +145,11 @@ jobs:
if-no-files-found: error

publish-pypi:
needs: publish-maven
needs: [guard, publish-maven]
if: always() && needs.guard.outputs.ok == 'true' &&
((needs.publish-maven.result == 'success' &&
(github.event_name == 'push' || inputs.jobs == 'all')) ||
(github.event_name == 'workflow_dispatch' && inputs.jobs == 'pypi-only'))
# Must match the PyPI trusted-publisher registration exactly
# (repo + workflow file + environment) — a mismatch is a 403.
environment: release
Expand All @@ -115,27 +158,41 @@ jobs:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout
- name: Checkout the tagged commit
uses: actions/checkout@v4
with:
ref: refs/tags/v${{ needs.guard.outputs.version }}

- name: Set up JDK 17 (pypi-only recovery rebuilds the jars from source)
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'

- name: Set up Python
uses: actions/setup-python@v5
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
if: github.event_name == 'push' || inputs.jobs == 'all'
uses: actions/download-artifact@v4
with:
name: interceptor-bundles
path: .

- name: Rebuild bundle jars from the tagged source (pypi-only recovery)
if: github.event_name == 'workflow_dispatch' && inputs.jobs == 'pypi-only'
run: mvn -B -q clean install -DskipTests

- name: Stamp wheel version from tag
run: |
VERSION="${{ needs.guard.outputs.version }}"
sed -i.bak -E 's/^version = "[^"]*"/version = "'"$VERSION"'"/' python/pyproject.toml
rm -f python/pyproject.toml.bak
grep "^version" python/pyproject.toml

- name: Bundle jars into the wheel
run: python python/build_hooks/bundle_jars.py

Expand All @@ -152,7 +209,11 @@ jobs:
packages-dir: python/dist/

smoke-test:
needs: [publish-maven, publish-pypi]
needs: [guard, publish-maven, publish-pypi]
if: always() && needs.guard.outputs.ok == 'true' &&
((needs.publish-maven.result == 'success' && needs.publish-pypi.result == 'success' &&
(github.event_name == 'push' || inputs.jobs == 'all')) ||
inputs.jobs == 'smoke-only')
runs-on: ubuntu-latest
steps:
- name: Set up JDK 17
Expand All @@ -166,15 +227,12 @@ jobs:
with:
python-version: '3.12'

- name: Derive version from tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"

# First release only: the Maven deployment sits in the Central Portal
# for manual review (autoPublish=false) and Central's CDN sync lags a
# few minutes even after approval — poll instead of failing fast.
- name: Install wheel from PyPI and assert bundled jars
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
python -m venv "$RUNNER_TEMP/venv"
. "$RUNNER_TEMP/venv/bin/activate"
Expand All @@ -190,6 +248,8 @@ jobs:
PY

- name: Resolve plugin + bundle from Maven Central
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
for _ in $(seq 1 30); do
if mvn -B -q dependency:get \
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ flowchart TD
top-level types only (§5.1). `interceptor-spark-*` → concrete Catalyst
internals for exactly one binary combination (the 3.5 pair share
`interceptor-spark-3.5-shared`; only `supportedVersion` differs per binary).
`maven-plugin`, `mutator-junit5`, and `pytest-spark-mutator` → `mutator-core`
`maven-plugin`, `mutator-junit5`, and `pytest-spark-mutation-testing` → `mutator-core`
(+ the dispatcher via the runtime), never a concrete shim directly. The
`interceptor-bundle-*` leaf modules exist to break a reactor cycle
(`interceptor-runtime` test-depends on a shim; a shim cannot therefore depend
Expand Down Expand Up @@ -155,7 +155,7 @@ socket duplicates a channel Py4J already provides. The design therefore
routes **all** control-plane calls through the existing Py4J `GatewayServer`
that PySpark already owns (`spark._sc._gateway`), calling directly into
`mutator-core` classes that are already on the driver classpath because
`pytest-spark-mutator` mounted the interceptor JAR into `spark.jars` at
`pytest-spark-mutation-testing` mounted the interceptor JAR into `spark.jars` at
session creation.

### 2.2 Two-Channel Model (mandatory)
Expand Down
2 changes: 1 addition & 1 deletion python/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"""Test package for pytest-spark-mutator."""
"""Test package for pytest-spark-mutation-testing."""
Loading