diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8da0c8f..f98f21f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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: | @@ -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 }} @@ -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 @@ -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 @@ -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 @@ -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" @@ -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 \ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0eef4e8..347c722 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 @@ -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) diff --git a/python/tests/__init__.py b/python/tests/__init__.py index cae4e54..d9215ba 100644 --- a/python/tests/__init__.py +++ b/python/tests/__init__.py @@ -1 +1 @@ -"""Test package for pytest-spark-mutator.""" +"""Test package for pytest-spark-mutation-testing."""