diff --git a/.github/workflows/build-pre-release.yml b/.github/workflows/build-pre-release.yml index f6473c1cc3..1f36b1cd11 100644 --- a/.github/workflows/build-pre-release.yml +++ b/.github/workflows/build-pre-release.yml @@ -14,7 +14,7 @@ on: default: "linux,macos-x86,macos-arm,windows,linux-aarch64" jobs: - build-and-publish: + build: uses: ./.github/workflows/lib-build.yml with: python-version: ${{ inputs.python-version }} diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 3412087b5d..94d91a7a04 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -1,4 +1,4 @@ -name: Build and upload to PyPi +name: Build wheels on push on: push: @@ -10,28 +10,6 @@ on: workflow_dispatch: jobs: - build-and-publish: + build: name: "Build wheels" uses: ./.github/workflows/lib-build.yml - - # Publishing is a separate job (not inside the reusable workflow) because PyPI Trusted Publishing - # requires the *caller* workflow path in the OIDC token. A reusable workflow would embed its own - # path instead, causing an `invalid-publisher` error on the PyPI side. - # See: https://github.com/pypa/gh-action-pypi-publish/issues/166 - publish: - name: "Publish wheels to PyPi" - if: ${{ endsWith(github.event.ref, 'scylla') }} - needs: build-and-publish - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - id-token: write - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: dist - merge-multiple: true - - - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - skip-existing: true diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 019fd5d982..5037cc102d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -18,16 +18,23 @@ on: - "setup.py" - "CHANGELOG.rst" workflow_dispatch: + workflow_call: + inputs: + release-commit: + description: Exact signed commit verified by the release workflow + required: true + type: string jobs: release: + if: github.event_name != 'push' || github.actor != 'scylladb-publisher' runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.repository.default_branch }} + ref: ${{ inputs.release-commit || github.event.repository.default_branch }} persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index 75d40f1084..c494491626 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -21,6 +21,12 @@ on: required: false default: "" + source-bundle: + description: "Artifact containing source.bundle and candidate.json from this run" + type: string + required: false + default: "" + ignore_tests: description: "Don't run tests" type: boolean @@ -88,6 +94,32 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.target_tag || github.sha }} + persist-credentials: false + + - name: Download prepared source + if: inputs.source-bundle != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.source-bundle }} + path: ${{ runner.temp }}/release-candidate + + - name: Check out prepared candidate + if: inputs.source-bundle != '' + shell: bash + run: | + python - <<'PYTHON' + import json, os, pathlib, subprocess + directory = pathlib.Path(os.environ['RUNNER_TEMP']) / 'release-candidate' + info = json.loads((directory / 'candidate.json').read_text()) + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + assert info['baseline'] == os.environ['GITHUB_SHA'] + git('bundle', 'verify', str(directory / 'source.bundle')) + git('fetch', str(directory / 'source.bundle'), 'refs/heads/release-candidate') + assert git('rev-parse', 'FETCH_HEAD') == info['candidate'] + assert git('rev-parse', 'FETCH_HEAD^{tree}') == info['tree'] + git('checkout', '--detach', info['candidate']) + PYTHON - name: Set build concurrency to the runner's CPU count shell: bash @@ -195,6 +227,8 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-${{ matrix.target }}-${{ matrix.os }} + overwrite: true + if-no-files-found: error path: ./wheelhouse/*.whl build-sdist: @@ -205,6 +239,32 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.target_tag || github.sha }} + persist-credentials: false + + - name: Download prepared source + if: inputs.source-bundle != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.source-bundle }} + path: ${{ runner.temp }}/release-candidate + + - name: Check out prepared candidate + if: inputs.source-bundle != '' + shell: bash + run: | + python - <<'PYTHON' + import json, os, pathlib, subprocess + directory = pathlib.Path(os.environ['RUNNER_TEMP']) / 'release-candidate' + info = json.loads((directory / 'candidate.json').read_text()) + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + assert info['baseline'] == os.environ['GITHUB_SHA'] + git('bundle', 'verify', str(directory / 'source.bundle')) + git('fetch', str(directory / 'source.bundle'), 'refs/heads/release-candidate') + assert git('rev-parse', 'FETCH_HEAD') == info['candidate'] + assert git('rev-parse', 'FETCH_HEAD^{tree}') == info['tree'] + git('checkout', '--detach', info['candidate']) + PYTHON - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 @@ -218,4 +278,6 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: source-dist + overwrite: true + if-no-files-found: error path: dist/*.tar.gz diff --git a/.github/workflows/release-matrix.yml b/.github/workflows/release-matrix.yml new file mode 100644 index 0000000000..3652cde442 --- /dev/null +++ b/.github/workflows/release-matrix.yml @@ -0,0 +1,214 @@ +# Adapted from python-driver-matrix at dcb16da4c0c7f87e64fcc7751e3214343f6fc3b2. +# Keep the runner pin and its version resolution/test steps in sync. +name: Release integration matrix +on: + workflow_call: + inputs: + release-version: + required: true + type: string +permissions: + contents: read + +jobs: + integration-test: + runs-on: ubuntu-22.04 + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + scylla: [LATEST, PRIOR, LTS-LATEST, LTS-PRIOR] + env: + WORKSPACE: ${{ github.workspace }} + CCM_DIR: ${{ github.workspace }}/scylla-ccm + PYTHON_DRIVER_DIR: ${{ github.workspace }}/driver + steps: + - name: Checkout matrix + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: scylladb/python-driver-matrix + ref: dcb16da4c0c7f87e64fcc7751e3214343f6fc3b2 + fetch-depth: 0 + persist-credentials: false + + - name: Select Scylla version query + id: scylla-query + env: + SCYLLA_VERSION: ${{ matrix.scylla }} + run: | + set -euo pipefail + + target="${SCYLLA_VERSION:-LATEST}" + filters="" + fallback_repo="" + needs_resolution=true + + # get-version treats dots as version-component separators. The regex components below + # match only calendar-release Docker tags shaped like YYYY.MINOR.PATCH, for example + # 2026.1.4. The selector after "and" then chooses from those matching tags: + # LAST.LAST.LAST = newest patch in the newest minor of the newest calendar major, + # LAST-1 = the second-newest calendar release overall (robust global position filter + # that still resolves when the newest minor only has a single patch release), + # LAST.1.LAST = newest LTS patch in the newest calendar major, and LAST-1.1.LAST = + # newest LTS patch in the previous calendar major. + case "$target" in + LTS-LATEST) + filters='^[0-9]{4}$.^[0-9]+$.^[0-9]+$ and LAST.1.LAST' + ;; + LTS-PRIOR) + filters='^[0-9]{4}$.^[0-9]+$.^[0-9]+$ and LAST-1.1.LAST' + fallback_repo="scylladb/scylla-enterprise" + ;; + LATEST) + filters='^[0-9]{4}$.^[0-9]+$.^[0-9]+$ and LAST.LAST.LAST' + ;; + PRIOR) + filters='^[0-9]{4}$.^[0-9]+$.^[0-9]+$ and LAST-1' + ;; + *) + needs_resolution=false + ;; + esac + + { + echo "target=$target" + echo "needs_resolution=$needs_resolution" + echo "repo=scylladb/scylla" + echo "fallback_repo=$fallback_repo" + echo "filters=$filters" + } >> "$GITHUB_OUTPUT" + + - name: Resolve Scylla version + if: ${{ steps.scylla-query.outputs.needs_resolution == 'true' }} + id: get-scylla-version + uses: scylladb-actions/get-version@a1dc4fedfb5684242148020b24e4150b1fe7ad08 # v0.4.5 + with: + source: dockerhub-imagetag + repo: ${{ steps.scylla-query.outputs.repo }} + filters: ${{ steps.scylla-query.outputs.filters }} + github-token: ${{ github.token }} + + - name: Resolve Scylla version fallback + if: ${{ steps.scylla-query.outputs.fallback_repo != '' && (steps.get-scylla-version.outputs.versions == '' || steps.get-scylla-version.outputs.versions == '[]') }} + id: get-scylla-version-fallback + uses: scylladb-actions/get-version@a1dc4fedfb5684242148020b24e4150b1fe7ad08 # v0.4.5 + with: + source: dockerhub-imagetag + repo: ${{ steps.scylla-query.outputs.fallback_repo }} + filters: ${{ steps.scylla-query.outputs.filters }} + github-token: ${{ github.token }} + + - name: Normalize inputs + id: resolve + env: + DRIVER_REF_INPUT: ${{ format('{0}-scylla', inputs.release-version) }} + SCYLLA_VERSION_TARGET: ${{ steps.scylla-query.outputs.target }} + SCYLLA_VERSION_NEEDS_RESOLUTION: ${{ steps.scylla-query.outputs.needs_resolution }} + SCYLLA_VERSION_RESOLVED: ${{ steps.get-scylla-version.outputs.versions }} + SCYLLA_VERSION_FALLBACK: ${{ steps.get-scylla-version-fallback.outputs.versions }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import re + + def first_version(versions_json: str) -> str: + if not versions_json: + return "" + versions = json.loads(versions_json) + if isinstance(versions, str): + return versions + if isinstance(versions, list): + return versions[0] if versions else "" + raise SystemExit(f"Unexpected get-version output: {versions!r}") + + def ccm_version(version: str) -> str: + if re.fullmatch(r"\d+(?:\.\d+)+", version): + return f"release:{version}" + return version + + driver_ref = os.environ["DRIVER_REF_INPUT"] + scylla_target = os.environ["SCYLLA_VERSION_TARGET"] + if os.environ["SCYLLA_VERSION_NEEDS_RESOLUTION"] == "true": + scylla_version = first_version(os.environ.get("SCYLLA_VERSION_RESOLVED", "")) + scylla_version = scylla_version or first_version(os.environ.get("SCYLLA_VERSION_FALLBACK", "")) + if not scylla_version: + raise SystemExit(f"Unable to resolve Scylla version target {scylla_target}") + else: + scylla_version = scylla_target + scylla_version = ccm_version(scylla_version) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: + fh.write(f"driver_ref={driver_ref}\n") + fh.write(f"scylla_version_target={scylla_target}\n") + fh.write(f"scylla_version={scylla_version}\n") + PY + + - name: Checkout launch commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: scylladb/python-driver + ref: ${{ github.sha }} + path: driver + fetch-depth: 0 + persist-credentials: false + + - name: Download candidate bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: ${{ runner.temp }}/candidate + + - name: Load candidate locally for version-specific matrix patches + working-directory: driver + env: + RELEASE_VERSION: ${{ inputs.release-version }} + run: | + python3 - <<'PYTHON' + import json, os, pathlib, subprocess + candidate = pathlib.Path(os.environ['RUNNER_TEMP']) / 'candidate' + info = json.loads((candidate / 'candidate.json').read_text()) + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + assert info['baseline'] == os.environ['GITHUB_SHA'] + assert info['version'] == os.environ['RELEASE_VERSION'] + git('bundle', 'verify', str(candidate / 'source.bundle')) + git('fetch', str(candidate / 'source.bundle'), 'refs/heads/release-candidate') + assert git('rev-parse', 'FETCH_HEAD') == info['candidate'] + assert git('rev-parse', 'FETCH_HEAD^{tree}') == info['tree'] + git('checkout', '--detach', info['candidate']) + git('-c', 'tag.gpgSign=false', 'tag', info['tag'], info['candidate']) + PYTHON + + - name: Checkout CCM repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: scylladb/scylla-ccm + ref: ad068edc3524309b54428c1babb31ab7e7af2c89 + path: scylla-ccm + persist-credentials: false + + - name: Run integration tests + env: + DRIVER_REF: ${{ steps.resolve.outputs.driver_ref }} + DRIVER_TYPE: scylla + PROTOCOLS: '3,4' + SCYLLA_VERSION: ${{ steps.resolve.outputs.scylla_version }} + TESTS: tests.integration.standard + run: | + set -euo pipefail + versions="${DRIVER_REF:-1}" + args=(./scripts/run_test.sh python3 ./main.py "$PYTHON_DRIVER_DIR" --driver-type "$DRIVER_TYPE" --versions "$versions" --protocols "$PROTOCOLS" --scylla-version "$SCYLLA_VERSION") + if [ -n "$TESTS" ]; then + args+=(--tests "$TESTS") + fi + "${args[@]}" + + - name: Retain integration reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-matrix-${{ matrix.scylla }}-${{ github.run_attempt }} + path: xunit/ + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..3f3095221e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,268 @@ +name: Release ScyllaDB Python Driver + +on: + workflow_dispatch: + inputs: + release-version: + description: Stable version to release (X.Y.Z) + required: true + type: string + dry-run: + description: Build, test, sign and verify without publication + type: boolean + default: false + skip-tests: + description: Skip project tests (release helper tests always run) + type: boolean + default: false + skip-integration-matrix: + description: Skip the external driver integration matrix + type: boolean + default: false + +concurrency: + group: python-driver-release + cancel-in-progress: false + +permissions: + contents: read + +env: + RELEASE_VERSION: ${{ inputs.release-version }} + +jobs: + prepare: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + actions: read + issues: read + outputs: + version: ${{ steps.prepare.outputs.version }} + retained: ${{ steps.prepare.outputs.retained }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Test trusted release helpers + run: python3 -m unittest discover -s scripts/release -p 'test_*.py' -v + - name: Validate launch, blockers and prepare metadata (or restore this run) + id: prepare + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release/release.py prepare + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.prepare.outputs.restored != 'true' + with: + name: release-candidate + path: ${{ runner.temp }}/candidate/ + if-no-files-found: error + retention-days: 90 + + build: + needs: prepare + if: needs.prepare.outputs.retained != 'true' + permissions: + contents: read + uses: ./.github/workflows/lib-build.yml + with: + source-bundle: release-candidate + ignore_tests: ${{ inputs.skip-tests }} + + metadata: + needs: [prepare, build] + if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: wheels-* + path: ${{ runner.temp }}/builds + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: source-dist + path: ${{ runner.temp }}/builds/source-dist + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: '3.13' + enable-cache: false + - name: Validate every platform artifact and package metadata + run: | + python3 scripts/release/release.py collect + uvx --from 'twine==6.2.0' twine check --strict "$RUNNER_TEMP"/pending/* + + integration: + needs: prepare + if: ${{ !inputs.skip-integration-matrix && needs.prepare.outputs.retained != 'true' }} + permissions: + contents: read + uses: ./.github/workflows/release-matrix.yml + with: + release-version: ${{ needs.prepare.outputs.version }} + + sign: + needs: [prepare, build, metadata, integration] + if: >- + ${{ inputs.dry-run && !cancelled() && needs.prepare.result == 'success' && + (needs.prepare.outputs.retained == 'true' || + (needs.build.result == 'success' && needs.metadata.result == 'success' && + (needs.integration.result == 'success' || + (inputs.skip-integration-matrix && needs.integration.result == 'skipped')))) }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # Dry runs receive signing secrets only, without an approval gate. + environment: release-dry-run + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Preserve trusted helpers from launch commit + run: | + mkdir -p "$RUNNER_TEMP/release-helpers" + cp scripts/release/*.py scripts/release/publisher.asc "$RUNNER_TEMP/release-helpers/" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: ${{ runner.temp }}/candidate + - name: Restore this run's signed checkpoint before accessing signing secrets + id: restore + env: + GH_TOKEN: ${{ github.token }} + REQUIRE_RETAINED: ${{ needs.prepare.outputs.retained }} + run: | + args=() + if [ "$REQUIRE_RETAINED" = true ]; then args+=(--require-retained); fi + python3 "$RUNNER_TEMP/release-helpers/release.py" restore "${args[@]}" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: steps.restore.outputs.retained != 'true' + with: + pattern: wheels-* + path: ${{ runner.temp }}/builds + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: steps.restore.outputs.retained != 'true' + with: + name: source-dist + path: ${{ runner.temp }}/builds/source-dist + - name: Sign tested tree, tag, packages and checksum manifest + if: steps.restore.outputs.retained != 'true' + env: + RELEASE_GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + RELEASE_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: python3 "$RUNNER_TEMP/release-helpers/release.py" sign + - name: Verify retained state with public certificate only + run: python3 "$RUNNER_TEMP/release-helpers/release.py" verify + # Publication cannot start unless this immutable recovery checkpoint succeeds. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.restore.outputs.retained != 'true' + with: + name: release-state + path: ${{ runner.temp }}/signed-state/ + if-no-files-found: error + retention-days: 90 + + publish: + needs: [prepare, build, metadata, integration] + if: >- + ${{ !inputs.dry-run && !cancelled() && needs.prepare.result == 'success' && + (needs.prepare.outputs.retained == 'true' || + (needs.build.result == 'success' && needs.metadata.result == 'success' && + (needs.integration.result == 'success' || + (inputs.skip-integration-matrix && needs.integration.result == 'skipped')))) }} + runs-on: ubuntu-24.04 + timeout-minutes: 40 + # This is the only approval gate: real signing and publication credentials. + environment: release + permissions: + contents: read + actions: read + issues: read + id-token: write + outputs: + commit: ${{ steps.preflight.outputs.commit }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Preserve trusted helpers from launch commit + run: | + mkdir -p "$RUNNER_TEMP/release-helpers" + cp scripts/release/*.py scripts/release/publisher.asc "$RUNNER_TEMP/release-helpers/" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: ${{ runner.temp }}/candidate + - name: Restore this run's signed checkpoint before accessing signing secrets + id: restore + env: + GH_TOKEN: ${{ github.token }} + REQUIRE_RETAINED: ${{ needs.prepare.outputs.retained }} + run: | + args=() + if [ "$REQUIRE_RETAINED" = true ]; then args+=(--require-retained); fi + python3 "$RUNNER_TEMP/release-helpers/release.py" restore "${args[@]}" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: steps.restore.outputs.retained != 'true' + with: + pattern: wheels-* + path: ${{ runner.temp }}/builds + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: steps.restore.outputs.retained != 'true' + with: + name: source-dist + path: ${{ runner.temp }}/builds/source-dist + - name: Sign tested tree, tag, packages and checksum manifest + if: steps.restore.outputs.retained != 'true' + env: + RELEASE_GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + RELEASE_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: python3 "$RUNNER_TEMP/release-helpers/release.py" sign + - name: Verify retained state with public certificate only + run: python3 "$RUNNER_TEMP/release-helpers/release.py" verify + # Publication cannot start unless this immutable recovery checkpoint succeeds. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.restore.outputs.retained != 'true' + with: + name: release-state + path: ${{ runner.temp }}/signed-state/ + if-no-files-found: error + retention-days: 90 + - name: Verify publisher bypass, signed state, blockers, baseline and PyPI hashes + id: preflight + env: + GH_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} + run: python3 "$RUNNER_TEMP/release-helpers/release.py" prepublish --pending pending + # OIDC belongs to this top-level workflow. Keep attestations enabled and + # upload only files whose absence was verified against PyPI JSON metadata. + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + if: steps.preflight.outputs.missing == 'true' + with: + packages-dir: pending/ + attestations: true + - name: Verify PyPI, push retained history and publish immutable GitHub release + env: + GH_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} + run: python3 "$RUNNER_TEMP/release-helpers/release.py" finish + + docs: + needs: publish + if: ${{ !cancelled() && needs.publish.result == 'success' && !inputs.dry-run }} + permissions: + contents: write + uses: ./.github/workflows/docs-pages.yml + with: + release-commit: ${{ needs.publish.outputs.commit }} diff --git a/MANIFEST.in b/MANIFEST.in index f67e7fc2fe..d7da4d4fc4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,5 @@ include cassandra/io/libevwrapper.c include cassandra/*.pyx include cassandra/*.pxd include cassandra/*.h -graft build-release \ No newline at end of file +graft build-release +include scripts/release/release.py scripts/release/signing.py scripts/release/publisher.asc diff --git a/scripts/release/.gitignore b/scripts/release/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/scripts/release/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/release/README.md b/scripts/release/README.md new file mode 100644 index 0000000000..c9116d6ac6 --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,191 @@ +# Python driver releases + +Dispatch **Release ScyllaDB Python Driver** (`release.yml`) on `master` with a new +canonical stable `X.Y.Z` version. The package remains `scylla-driver`; the signed +annotated tag is `X.Y.Z-scylla`. The package stays at the released version after +publication. Maintenance branches, prereleases and historical republishing are +outside this workflow. + +## Validation and approval + +The launch SHA and preparation date are recorded once in `release-candidate`. +Preparation updates the package version, promotes changelog entries to a dated +release section, keeps an empty `Unreleased` heading, adds the documentation tag, +updates its latest version, and updates the installation output example. A Git +bundle exports the candidate commit and tree to every builder. + +The existing five-platform wheel builder retains its interpreter coverage and +runs its full configured wheel tests. Every platform artifact and the source +distribution must be present; all distribution names and archive metadata must +match the requested version. Twine checks package metadata. The integration +matrix checks out `python-driver-matrix` explicitly at +`dcb16da4c0c7f87e64fcc7751e3214343f6fc3b2`, loads the candidate bundle locally, +and runs protocols 3 and 4 against `LATEST`, `PRIOR`, `LTS-LATEST` and `LTS-PRIOR`. +A local candidate tag selects the runner's version-specific patches; it is never +pushed. The copied alias-resolution steps must be updated with the runner pin. + +`skip-tests` skips project wheel tests. Release helper tests always run, and the +external matrix has its own `skip-integration-matrix` input. Open issues carrying +`release-blocker` are checked across every page before validation and immediately +before publication. Releases are serialized without cancelling an active run. + +For a real release, only the `publish` job references the approval-protected +`release` environment. Preparation, builds, project and matrix tests, metadata +checks and downstream docs have no approval gate or release secrets. After +approval, that one job signs and publishes: helpers from the launch SHA sign the +exact tested tree as `ScyllaDB Publisher `, with: + +- Primary certificate: `786703A4F7AC1E986C7CFB2318ED6CC7EBE7C5F9` +- Signing subkey: `7C0A5E8366A579484D1D352C2EC9B987C56EF1CA` + +The private export must contain only the protected signing subkey. The primary +secret, other fingerprints, an unprotected export and an incorrect passphrase +are rejected. Dedicated temporary keyrings and agents are cleaned on failure +and handled cancellation. Only the signing step receives signing credentials; +builds receive none. Credential jobs parse archives and Git objects without +importing the driver or running candidate build code. + +Public-only verification checks the commit, annotated tag, distributions and +checksum signatures. `release-state` retains the exact packages, signatures, +public certificate, signed Git bundle, candidate baseline and publication +identity **before any upload**. Its signed manifest covers all retained payloads. +Artifacts are retained for 90 days; archive a copy before expiry when recovery +may be needed. + +## Publication + +The top-level `release.yml` job uses PyPI OIDC with attestations enabled. Before +upload, it verifies the dedicated publisher team bypass, repository immutability, +release blockers, the remote baseline/tag and existing PyPI file hashes. It stages +only missing packages for upload, then checks that PyPI exposes exactly the +expected filenames and SHA-256 hashes. It pushes the retained signed commit and +tag atomically using the protected publisher credential, without force. + +GitHub publication creates a draft, attaches all packages, signatures, checksums, +state, signed bundle and public certificate, verifies asset digests, then +publishes. Docs run explicitly at the signed commit only after the release's +immutable flag and remote tag identity are verified. Automatic docs deployment +is suppressed for pushes made by `scylladb-publisher`. + +`dry-run` needs no environment approval. Its signing job uses `release-dry-run`, +which is restricted to `master`, has no required reviewers and contains only +`GPG_PRIVATE_KEY` and `GPG_PASSPHRASE`. It uses the existing Python signing +certificate and has no publisher credential or OIDC permission. Real signing and +publication run together in the single approval-protected `publish` job. + +`dry-run` performs preparation, builds, tests, signing and public verification, +and retains the signed checkpoint. It stops before PyPI upload, Git pushes, +GitHub release creation and docs deployment. Run a full dry run after +this workflow reaches `master`, before the first real release. PyPI OIDC itself +is exercised by the first real upload. + +## Cutover + +Configured during this implementation: + +- `release` environment: approval by `scylladb/drivers`, self-review permitted, + administrator bypass disabled, deployments restricted to branch `master`. +- `release-dry-run` environment: no required reviewers, deployments restricted + to `master`, with only the two signing secrets and no publisher credential. +- Environment secrets `GPG_PRIVATE_KEY`, `GPG_PASSPHRASE`, and `PUBLISHER_TOKEN`. + The existing `~/.repos-gpg/scylladb/python-driver/signing-private.asc` and + passphrase were supplied through stdin; only `publisher.asc` is committed. + The publisher credential belongs to the existing `scylladb-publisher` account. +- Python public certificate registered on `scylladb-publisher`. +- Repository release immutability enabled for future releases. + +Required before the first real release: + +1. An organization administrator must create the closed + `scylladb/python-driver-publisher` team containing only `scylladb-publisher`, + grant that team write access to this repository, and add it as an **Always** + bypass actor on the `master` pull request ruleset (currently ruleset + `3470144`). Retain the pull request rule and other existing rules and actors. + The available token cannot create this team because it lacks `admin:org`. + Publication checks team membership and every effective pull request ruleset + before uploading to PyPI; a missing bypass blocks publication. +2. A PyPI project owner must register a GitHub trusted publisher for project + **scylla-driver**, owner **scylladb**, repository **python-driver**, workflow + **release.yml**, environment **release**. See + [PyPI registration](https://docs.pypi.org/trusted-publishers/adding-a-publisher/). + No authenticated PyPI management connection was available during implementation. +3. Merge this PR and retire the previous PyPI publisher registrations for + `build-push.yml` and any old manual upload workflows. Their workflows retain + builds; the PyPI upload job is removed in this PR. Verify the actual registered + list in PyPI rather than deleting unrelated publishers. +4. Dispatch a `dry-run` on `master` with both test skips disabled. + Inspect the build, matrix and signing results before dispatching a real release. + +For key rotation, update the public certificate and fingerprints together, +register the certificate on the publisher account, replace environment secrets, +and repeat the full dry run. Do not export or upload the primary secret. + +## Reruns and manual recovery + +Use **Re-run failed jobs** or **Re-run all jobs** on the original workflow run. +Do not dispatch a new run for the same published version. Preparation restores +that run's candidate and original date. If `release-state` already exists, +builds and signing are skipped and the retained bytes and signed history are +verified. The credential job rechecks this run's checkpoint even when only the +failed publication job is rerun and preparation still has its original outputs. +It downloads the same signed state and never re-signs it. An expected checkpoint +that disappears or cannot be read blocks the run instead of enabling signing. +Existing PyPI files must match the original hashes; only missing files are +uploaded. GitHub draft uploads resume at missing assets. Already published +releases must be immutable and complete. Existing signatures are never replaced, +and existing tags are never moved. + +A failure before the signed checkpoint is retained has no external publication; +rerunning may rebuild and sign. A checkpoint upload failure prevents publication. +Once the checkpoint exists, any mismatch, expiration or ambiguous state requires +manual investigation. Never delete the recovery artifact to force regeneration. + +Download a checkpoint with: + +```sh +gh run download RUN_ID --repo scylladb/python-driver \ + --name release-state --dir /safe/location/release-state +``` + +The signed `release.json` records the baseline SHA, run ID, candidate tree, signed +commit, tag object and package hashes. For local verification, check out the +**recorded baseline** in a fresh clone, use the helpers from that checkout, and +set `GITHUB_REPOSITORY=scylladb/python-driver`, `GITHUB_REF=refs/heads/master`, +`GITHUB_SHA` to the recorded baseline and `GITHUB_RUN_ID` to the original run ID. +Then run `python3 scripts/release/release.py verify --state /safe/location/release-state`. +No signing credentials are needed. Verification imports only the public key and +loads signed Git objects from the retained bundle. + +If `master` advances beyond both the original baseline and the retained signed +commit, the workflow refuses further publication. Pause release activity and +inspect the retained state, current Git history, PyPI filenames/hashes and GitHub +assets. Do not rebase, cherry-pick, amend or re-sign the retained release commit; +do not replace signatures, overwrite PyPI files, or move an existing tag. A +maintainer must decide an explicit history-recovery procedure or abandon that +version and release a new higher version. Any PyPI files already published remain +immutable evidence even if the release is abandoned. The workflow never repairs +branch history automatically. + +If pushing fails after PyPI succeeds, fix the credential/ruleset or network issue +and rerun the original run while its baseline is still valid. If GitHub asset +upload fails, resume its draft. If immutability verification fails after GitHub +publication, investigate repository settings and release state before rerunning; +docs remain gated. Keep the original artifacts throughout recovery. + +## Local checks + +```sh +uv run pytest tests/unit/test_release_helpers.py +python3 -m unittest discover -s scripts/release -p 'test_*.py' -v +actionlint .github/workflows/release.yml .github/workflows/release-matrix.yml +``` + +The primary unit suite covers portable metadata and publication guards on the +supported wheel platforms. Release preparation also runs the complete dedicated +helper suite, including its disposable-key and cancellation checks. + +Tests use disposable protected GPG keys and mocked remote services. They cover +metadata/version checks, blocker pagination, candidate identities, branch +movement, artifact completeness, signature verification and cleanup, partial +uploads, conflicting hashes, publication order and immutable release recovery. +Platform wheel builds and live Scylla integration run in GitHub Actions. diff --git a/scripts/release/publisher.asc b/scripts/release/publisher.asc new file mode 100644 index 0000000000..ccf70fc7da --- /dev/null +++ b/scripts/release/publisher.asc @@ -0,0 +1,64 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGrXjMcBEADnHbLr2/qXBckByxDWxth9tvmuGkEI25jxoh4AP1c7wlGPEzqM +flfNmxhV5ItDmuWficv+f16FMTKunlkzd7UK8od/tC5kEQ8S0u0O7djoqgdWL9AN +I4UF8QgBdAxudth13+YyRWH4kaP9dINEDpQ3rVrznkzvhlr9oKZTLw2YRlUtVVPy +UyMlWQI1jlY6/ZF10+muWaiyYMBh8OXtjtK9kSOBj9pL3G8gTlWfUAIeuPcA5rOG +9UezrmQVe5acDN77uZN6bNSuNB6S2L1qmgLZ0Rr6QNzfjS6E9VahXC16MiSPgm8r +i3E529wQyS3lSImaV+6zgiU1FxHzf9Wxyq7XCTwU8z3vRzzyXX3HFbt4iu8Pl/9j +w+PRd3y8l3D5qr9g4bZWHHuT8nh7lPedFSf2dTHArpc+dMTykXqG+gM496atRGCL +RB7mttfMh//WUG7xdWA3xm81UCms3Ltz6WNmLOpx2T1zUVgwCIawgOtZZN6mMyXH +Q5CtM05lGhcznQdIz0WP3KUyHvNylZy1YdqkbwVElnrjFdNyRxP0oTyhI1vapY+2 +AvNuCDOA16eGYaPKWNunPqf6jbftzNgHrKa9Bv95Ag73BT9eiKMwV5GK+lXH3BHJ +pu5dyHYVnINwcEbs9IvmNe+3BoMtwuzghj0dv+FCMRkK40oN3tJTTku+ywARAQAB +tEdTY3lsbGFEQiBQdWJsaXNoZXIgKHNjeWxsYWRiL3B5dGhvbi1kcml2ZXIpIDxw +dWJsaXNoLmNvZGVAc2N5bGxhZGIuY29tPokCVwQTAQoAQRYhBHhnA6T3rB6YbHz7 +IxjtbMfr58X5BQJq14zHAhsBBQkDwmcABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4H +AheAAAoJEBjtbMfr58X52msP/j1ehN1Ywa8LvPFKyox89++DlW+wpgh1LXAe9Kd/ ++7bYDk1Mx0jUI/PzXzbu1fk0hItvlYCYx5Ofho2QDk/65mNfE7hMe3Ap3IDa45Qh +tqRN2o9/MIaKsyJI036uHQc7YDeqN4jKCA7yxH/nr0+RdU0c6WeAm0raEmAjKcFs +T8RsVf1eWS+aI+VCwfWCK585iB4Lj3f6rzBzbnSbMqB7f5WXdE5zfCslDPcb2VdT +LF48XpVnVGYA/UzLHcxaCL4ELI13pg6UZf/SnD7zSI2TqARVblFzzUHEMxk4u4rt +/6NPT+jsb25u63/NeMFd5EGxoHtRsr0I4lrfI555RNogFjRvIyI5ZeARJlcGE9II +wNUhj/lJvRam+fO3svBJQg61GnCODjXjiiyJfwNtjSayv/1vW+rVeHma8Lra1QVJ +cPyPH/m/X7C4SJgM6R+bSMK2Zm07lxqjcOHGESdFV7plx7YAmHC0/Vk5qXMlAg4J +onUF462uA+S1PNycRsoTr9HFj7KdtEo+FQxF/dYzkujdWutYv2EO53/Hb4xYpYnb +SfDJr5uHR/qB+ZC6tBkwRmuvtvRClOenDXAPypAcHLrypTK9O1umvw6br5xDbqWr +OMB0CA3xJlK6maGDY84BKeIR+jIyhroLH2oThJ+kR+quuJMUQ5/tGQuLQP5cfud0 +C73UuQINBGrXjMkBEADQEuqFjQ+8W22dFTtRgSuMefG+nGnSu7xlCBh8BX5buVcp +YRg6P5wg9yAGS5Sa9ZiaUED3c840Zd7fAXIMj6ZpCD8ncJ4hSu3DXsERS/aueeH0 +SZ4FfmLVN6xfDqo0tENTXCiX8jvX+lbCrehptL10j0UOH5f1Sok1VOkJKT5E/KwV +ZwyOF+GSNcFuaZbodRCtVDPhbz0znlpRxlxZum7+HgKP/N4W6nfTo9h9We9olFJg +vGWqCACOgIUH+zjE45gByLjaYIZ4/aOpmcSY8/VcOavlhULKfGWpJYMOWqZ1jFKF +onuZgFp+uXetcwk6QEKB3FE/X0+6uSOaZaZrzYx7Uy8iov5Ugjs9QP4hsqG9bwf4 +vtu1RpGLRgZvdIXfo0EnzrGf46m7A812dKcx6pScwjkCDRQrR8Kt200bQ9wj5fsy ++T9vLdN4RPZYfg/fv5T6IWhxweVGVqymkP3Autv6QbLfSMOFJPkjhDR2MYlQxDxv +NIrDJpLsqfaSeCXk1DN1mYImActJhGSAM0uvd2EUYvz3+DpSh4kWwfVXFbyveKUH +zXlYwhZpQuu2CmOmhVF3sjp1pssUTLc31lgcmPkeQXJXwHejCd/RKb4oBgU6REaf +ifmseoKx5/7yjqj21sMtYiRJjf8dkhLQ0zz0Z2yEDPFSZ+D9Yv+NdPbB7SR1nwAR +AQABiQRyBBgBCgAmFiEEeGcDpPesHphsfPsjGO1sx+vnxfkFAmrXjMkCGwIFCQPC +ZwACQAkQGO1sx+vnxfnBdCAEGQEKAB0WIQR8Cl6DZqV5SE0dNSwuybmHxW7xygUC +ateMyQAKCRAuybmHxW7xyuI2D/9nrl/yveOKosgynJWayPZGLh6kf9WOpPxNH6Qm +5kCu3G12e7I+CNadM7w+xgnjAW1WTrZBHptQSkHj1w9szKE7rnIYSo0zUDBiZt9G +NdOA+0P90k15GImCFoumSIy3ALtH9PKQpTOi6XO+PrLq0Y48mCu+sYhNRkcSyIzs +Y4EJSpwjlNgmL4TRpg8x6Dn9dt+G1LlTJZnh5o8mlRm5dklUygHuymS/EXZJAUhZ +c5a9nTjGEhiokznmgabRZIF2A00rkh0HPCh9vihblRVEv4dRCPFRda5lnUOsLZkJ +JtHMlf4t9PfBRe+48AhMJLwoxY+aK4XfqkZeXp+MRBw6DvjEM4tj+Rj39otXsfoj +XrcEMMavX+RRNTzFzYG8t58Fd0FAVxxNMjDvU6u3X6rObdBM5ZHRviFWmM/LjX6K +j1cRq9vxpKRdUbppW8KO6H0gm2J2ToCLzyVSaXHA2eyil1FdgNKa+6ltYRuTipJ9 +5r6tzOUlH6B5JlQOijWslhpHVR/kqYLUf66hvvzHsIzpnlBWvVHGcOq6Y+jocC+b +ZyBE/wOll+fvBwF01mGqR80q2zbhY3kZOES2aEbyuAn3zTz0Gk/rVcU+/irUCHIz +vLTtWeP/0W/cLEtK7T4Zow3jUKjlGNaCD0XFSgD26nADgWE8RORkNLsPFB53MkQs +6RVes3C+D/9Yq5q8Qy0YcARuPo/DwcCsfgDMk1bs7sWx45/1c/SOV8AvudKft+Si +twUiNsJXCM14ZSiMtCg80WGKgkhwmVOXzvtOQ3946NRWP8q9oNVqjHWyZQ5d6khs +4H2bcEilGjynbZUw+KAWyoqltJMcQJFcwOIiz1/DNeLULf99pRfa5gqfTtW8mqaZ +KbOruuC7OHSAduXPZG4f7kzvGtiZevX9Ol8dq2bIlalcj7Mmz+qmUsB+mnyo3kOC +pQKqTubIcBBvxHFQLetmtoyq3ZoFeVuXcmlrx1JGks0TD2VbC4PuSd47LOFv2700 +w7CN83kDyX9le6pgZvA5iFtalSeNu+zR8FLpVVLuzQdhk2AiSdbmENRFna0iM8W2 +K5KDxbio9y2Tr2NQ2ujpu2Wp/+j/Kq59c19pzob5j14DOEsP7cmW69X9S/elqfCY +QcYr9wOp33lQqS5UDyitLwJUPuPpCCHSz/O7KU7sF1MDU6MKU7bHyUVV751ZIgno +hkV2oqRRJT4XxtiZaLlPYshndwR1gH+f1Yljx/ZnD8xoDP+BYwfOUjtVuV+OfXO1 +IpwPr5c0syXMHvut7SJIarwm3jSBGU9PbxD8AzLbiMaAyNqc/drdhm6fJ3f1hdsD +yuln9Clq+jlbRvVRc8e+5AR3k2dJeF2LyU9hLj3qNmeZ0AtGz8dCeA== +=3a8Y +-----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/release/release.py b/scripts/release/release.py new file mode 100644 index 0000000000..3462be0a1f --- /dev/null +++ b/scripts/release/release.py @@ -0,0 +1,1026 @@ +#!/usr/bin/env python3 +""" +Prepare, retain, verify and publish one stable Python driver release. + +Run only helpers checked out at the workflow launch SHA. No driver +imports or build commands run in jobs with release credentials. See +README.md for recovery. +""" + +import argparse +from datetime import date +from email.parser import BytesParser +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tarfile +import time +from urllib.error import HTTPError +from urllib.request import urlopen +import zipfile + +import signing + +REPOSITORY = "scylladb/python-driver" +CANDIDATE_REF = "refs/heads/release-candidate" +SIGNED_REF = "refs/heads/release-signed" +BUILD_ARTIFACTS = { + "source-dist", + "wheels-linux-ubuntu-24.04", + "wheels-linux-aarch64-ubuntu-24.04-arm", + "wheels-macos-x86-macos-15-intel", + "wheels-macos-arm-macos-15", + "wheels-windows-windows-2022", +} + + +class Invalid(ValueError): + pass + + +def require(condition, message): + if not condition: + raise Invalid(message) + + +def command(*args, payload=None, env=None): + result = subprocess.run( + args, + input=payload, + capture_output=True, + text=True, + env=env, + timeout=120, + ) + require( + result.returncode == 0, f"{args[0]} operation failed; output withheld." + ) + return result.stdout.strip() + + +def git(*args, **kwargs): + return command("git", *args, **kwargs) + + +def gh(*args, payload=None, missing_ok=False): + result = subprocess.run( + ["gh", "api", "--hostname", "github.com", "--include", *args], + input=json.dumps(payload) if payload is not None else None, + capture_output=True, + text=True, + timeout=120, + ) + headers, separator, body = result.stdout.partition("\n\n") + status = headers.splitlines()[0].split() if headers else [] + require( + separator and len(status) > 1 and status[0].startswith("HTTP/"), + "GitHub request failed; response withheld.", + ) + if missing_ok and status[1] == "404": + return None + require( + result.returncode == 0 and status[1].startswith("2"), + "GitHub request failed; response withheld.", + ) + return json.loads(body) if body else None + + +def api(endpoint, **kwargs): + return gh("repos/" + REPOSITORY + "/" + endpoint, **kwargs) + + +def pages(endpoint, key=None): + page = 1 + while True: + value = api( + endpoint + + ("&" if "?" in endpoint else "?") + + f"per_page=100&page={page}" + ) + items = value[key] if key else value + yield from items + if len(items) < 100: + break + page += 1 + + +def blockers(): + issues = [ + item + for item in pages("issues?state=open&labels=release-blocker") + if "pull_request" not in item + ] + require( + not issues, + "Open release blockers: " + + ", ".join(item["html_url"] for item in issues), + ) + + +def stable(version): + require( + re.fullmatch( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version + ), + "Release version must be a canonical X.Y.Z stable version.", + ) + return tuple(map(int, version.split("."))) + + +def pypi(version): + stable(version) + try: + with urlopen( + f"https://pypi.org/pypi/scylla-driver/{version}/json", timeout=60 + ) as response: + return json.load(response)["urls"] + except HTTPError as error: + if error.code == 404: + return None + raise + + +def read_json(path): + return json.loads(Path(path).read_text()) + + +def write_json(path, value): + Path(path).write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def sha256(path): + with Path(path).open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def output(**values): + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as stream: + for key, value in values.items(): + stream.write(f"{key}={value}\n") + + +def restore(name, directory): + """ + Artifacts belong to this run, never a previous dispatch or a moving + branch. + """ + matches = [ + item + for item in pages( + f"actions/runs/{os.environ['GITHUB_RUN_ID']}/artifacts", + "artifacts", + ) + if item["name"] == name + ] + require(len(matches) <= 1, "Ambiguous recovery artifacts.") + if not matches: + return False + require( + not matches[0]["expired"], + "Recovery artifact expired; manual recovery required.", + ) + command( + "gh", + "run", + "download", + os.environ["GITHUB_RUN_ID"], + "--repo", + REPOSITORY, + "--name", + name, + "--dir", + str(directory), + ) + return True + + +def restore_state(directory, require_retained=False): + """Recheck this run's checkpoint even when preparation was not rerun.""" + retained = restore("release-state", directory) + require( + retained or not require_retained, + "Expected signed checkpoint is missing; manual recovery required.", + ) + output(retained=str(retained).lower()) + + +def replace_once(text, pattern, replacement): + text, count = re.subn( + pattern, lambda match: replacement(match), text, flags=re.M + ) + require(count == 1, "Metadata layout changed; update the release helper.") + return text + + +def prepare_metadata(root, version, released): + wanted = stable(version) + date.fromisoformat(released) + init = root / "cassandra/__init__.py" + text = init.read_text() + current = re.findall( + r"^__version_info__ = \((\d+), (\d+), (\d+)\)$", text, re.M + ) + require( + len(current) == 1 and wanted > tuple(map(int, current[0])), + "Release must increase the package version.", + ) + init.write_text( + replace_once( + text, + r"^__version_info__ = .+$", + lambda m: "__version_info__ = " + str(wanted), + ) + ) + changelog = root / "CHANGELOG.rst" + text = changelog.read_text() + require( + text.startswith("Unreleased\n==========\n"), + "Expected leading Unreleased heading.", + ) + boundary = re.search(r"^\d+\.\d+\.\d+\n=+\n", text, re.M) + require( + boundary is not None, "Expected previous stable changelog section." + ) + entries = text[len("Unreleased\n==========\n") : boundary.start()].strip() + require(entries, "No Unreleased changelog entries.") + require( + not re.search(r"^" + re.escape(version) + r"\n=+", text, re.M), + "Release changelog section already exists.", + ) + changelog.write_text( + "Unreleased\n==========\n\n" + + version + + "\n" + + "=" * len(version) + + "\n" + + released + + "\n\n" + + entries + + "\n\n" + + text[boundary.start() :] + ) + conf = root / "docs/conf.py" + text = conf.read_text() + tag = version + "-scylla" + text = replace_once( + text, + r"^TAGS = \[\n([\s\S]*?)^\]", + lambda m: m.group(0)[:-1] + f" '{tag}',\n]", + ) + text = replace_once( + text, r"^LATEST_VERSION = .+$", lambda m: f"LATEST_VERSION = '{tag}'" + ) + conf.write_text(text) + installation = root / "docs/installation.rst" + installation.write_text( + replace_once( + installation.read_text(), + r'It should print something like "[^"]+"\.', + lambda m: f'It should print something like "{version}".', + ) + ) + + +def validate_candidate(info): + stable(info["version"]) + require( + os.environ.get("GITHUB_REPOSITORY") == REPOSITORY + and os.environ.get("GITHUB_REF") == "refs/heads/master", + "Unexpected release launch context.", + ) + require( + not os.environ.get("RELEASE_VERSION") + or info["version"] == os.environ["RELEASE_VERSION"], + "Retained release version differs from dispatch.", + ) + require( + info["baseline"] == os.environ["GITHUB_SHA"] + and info["repository"] == REPOSITORY + and info["run_id"] == os.environ["GITHUB_RUN_ID"], + "Candidate launch identity changed.", + ) + require( + info["tag"] == info["version"] + "-scylla", + "Candidate tag/version mismatch.", + ) + date.fromisoformat(info["date"]) + + +def load_candidate(directory): + info = read_json(directory / "candidate.json") + validate_candidate(info) + git("bundle", "verify", str(directory / "source.bundle")) + git( + "fetch", + str(directory / "source.bundle"), + CANDIDATE_REF + ":" + CANDIDATE_REF, + ) + require( + git("rev-parse", CANDIDATE_REF) == info["candidate"] + and git("rev-parse", CANDIDATE_REF + "^{tree}") == info["tree"] + and git("rev-parse", CANDIDATE_REF + "^") == info["baseline"], + "Candidate bundle identity differs.", + ) + return info + + +def prepare(directory, version): + require( + os.environ.get("GITHUB_REF") == "refs/heads/master" + and os.environ.get("GITHUB_REPOSITORY") == REPOSITORY, + "Launch releases from master upstream.", + ) + stable(version) + blockers() + directory.mkdir(parents=True, exist_ok=True) + retained = restore("release-state", directory.parent / "retained") + restored = restore("release-candidate", directory) + if restored: + info = load_candidate(directory) + require(info["version"] == version, "Recovery version changed.") + else: + require( + not retained, + "Signed state exists without its candidate; " + "manual recovery " + "required.", + ) + baseline = git("rev-parse", "HEAD") + require(baseline == os.environ["GITHUB_SHA"], "Launch checkout moved.") + require( + not git("status", "--porcelain", "--untracked-files=no"), + "Launch checkout is dirty.", + ) + tag = version + "-scylla" + existing = git("tag", "--list", tag) + require( + not existing and not remote_ref("refs/tags/" + tag), + "Release tag already exists.", + ) + require( + pypi(version) is None, + "Release version is already published on PyPI.", + ) + require( + all( + stable(version) > stable(value[:-7]) + for value in git("tag", "--list", "*-scylla").splitlines() + if re.fullmatch(r"\d+\.\d+\.\d+-scylla", value) + ), + "Version must increase existing releases.", + ) + check_branch({"baseline": baseline}) + released = date.today().isoformat() + prepare_metadata(Path.cwd(), version, released) + git( + "add", + "cassandra/__init__.py", + "CHANGELOG.rst", + "docs/conf.py", + "docs/installation.rst", + ) + tree = git("write-tree") + candidate_env = { + **os.environ, + "GIT_AUTHOR_NAME": "ScyllaDB Publisher", + "GIT_AUTHOR_EMAIL": "publish.code@scylladb.com", + "GIT_COMMITTER_NAME": "ScyllaDB Publisher", + "GIT_COMMITTER_EMAIL": "publish.code@scylladb.com", + "GIT_AUTHOR_DATE": released + "T00:00:00Z", + "GIT_COMMITTER_DATE": released + "T00:00:00Z", + } + candidate = git( + "-c", + "commit.gpgsign=false", + "commit-tree", + tree, + "-p", + baseline, + payload=f"Release {version}\n", + env=candidate_env, + ) + git("update-ref", CANDIDATE_REF, candidate) + git( + "bundle", "create", str(directory / "source.bundle"), CANDIDATE_REF + ) + info = dict( + repository=REPOSITORY, + run_id=os.environ["GITHUB_RUN_ID"], + version=version, + tag=tag, + baseline=baseline, + date=released, + candidate=candidate, + tree=tree, + ) + write_json(directory / "candidate.json", info) + output( + version=version, + retained=str(retained).lower(), + restored=str(restored).lower(), + ) + + +def remote_ref(ref): + value = git("ls-remote", "https://github.com/" + REPOSITORY + ".git", ref) + require(not value or len(value.splitlines()) == 1, "Ambiguous remote ref.") + return value.split()[0] if value else None + + +def check_branch(info, signed=None): + current = remote_ref("refs/heads/master") + require( + current == info["baseline"] + or (signed is not None and current == signed), + "master advanced beyond the validated " + "baseline; manual recovery required. Do not " + "rebase retained " + "history.", + ) + + +def package_hashes(directory, version, signed=False): + "Read archive metadata without importing driver code or extracting files." + files = sorted( + path + for path in directory.iterdir() + if not (signed and path.name.endswith(".asc")) + ) + require( + files + and all( + p.is_file() + and not p.is_symlink() + and p.name.endswith((".whl", ".tar.gz")) + for p in files + ), + "Unexpected or missing package files.", + ) + require( + sum(p.name.endswith(".tar.gz") for p in files) == 1, + "Expected exactly one source distribution.", + ) + require(any(p.name.endswith(".whl") for p in files), "No wheels found.") + result = {} + for path in files: + if path.name.endswith(".whl"): + with zipfile.ZipFile(path) as archive: + names = [ + name + for name in archive.namelist() + if name.endswith(".dist-info/METADATA") + ] + require(len(names) == 1, "Wheel metadata is ambiguous.") + content = archive.read(names[0]) + require( + path.name.startswith("scylla_driver-" + version + "-"), + "Wheel filename version differs.", + ) + else: + require( + path.name + in ( + f"scylla_driver-{version}.tar.gz", + f"scylla-driver-{version}.tar.gz", + ), + "Source filename version differs.", + ) + with tarfile.open(path) as archive: + members = [ + m + for m in archive.getmembers() + if m.name.count("/") == 1 + and m.name.endswith("/PKG-INFO") + and m.isfile() + ] + require(len(members) == 1, "Source metadata is ambiguous.") + content = archive.extractfile(members[0]).read() + metadata = BytesParser().parsebytes(content) + require( + metadata["Name"] == "scylla-driver" + and metadata["Version"] == version, + "Package metadata name/version differs.", + ) + result[path.name] = sha256(path) + return result + + +def collect(builds, destination, version): + require( + {path.name for path in builds.iterdir()} == BUILD_ARTIFACTS, + "Incomplete platform artifacts.", + ) + destination.mkdir(parents=True, exist_ok=False) + for directory in builds.iterdir(): + files = list(directory.iterdir()) + require( + files + and all( + p.is_file() + and p.name.endswith( + ".tar.gz" if directory.name == "source-dist" else ".whl" + ) + for p in files + ), + "Empty or unexpected build artifact.", + ) + for path in files: + require( + not (destination / path.name).exists(), + "Duplicate distribution filename.", + ) + shutil.copyfile(path, destination / path.name) + package_hashes(destination, version) + + +def create_signed(candidate_dir, builds, directory): + info = load_candidate(candidate_dir) + require(not directory.exists(), "Refusing to overwrite signed state.") + directory.mkdir() + shutil.copyfile( + candidate_dir / "candidate.json", directory / "candidate.json" + ) + collect(builds, directory / "dist", info["version"]) + hashes = package_hashes(directory / "dist", info["version"]) + require( + not git("tag", "--list", info["tag"]), + "Local release tag exists; restore signed state instead.", + ) + with signing.signing_session() as env: + # commit-tree signs the exact tested tree, without checking out or + # executing candidate code. + signed = git( + "commit-tree", + "-S" + signing.SIGNING + "!", + info["tree"], + "-p", + info["baseline"], + payload=f"Release {info['version']}\n", + env=env, + ) + git("update-ref", SIGNED_REF, signed) + git( + "tag", + "-s", + info["tag"], + signed, + "-m", + f"Release {info['version']}", + env=env, + ) + git( + "bundle", + "create", + str(directory / "signed.bundle"), + SIGNED_REF, + "refs/tags/" + info["tag"], + ) + state = dict( + info, + commit=signed, + tag_object=git("rev-parse", "refs/tags/" + info["tag"]), + packages=hashes, + ) + write_json(directory / "release.json", state) + shutil.copyfile(signing.PUBLIC_KEY, directory / "publisher.asc") + files = sorted((directory / "dist").iterdir()) + files += [ + directory / name + for name in ( + "candidate.json", + "release.json", + "signed.bundle", + "publisher.asc", + ) + ] + manifest = directory / "SHA256SUMS" + manifest.write_text( + "".join(f"{sha256(path)} {path.name}\n" for path in files) + ) + signing.sign_files( + [ + *(path for path in files if path.name != "publisher.asc"), + manifest, + ], + env, + ) + verify_state(directory) + + +def verify_state(directory): + state = read_json(directory / "release.json") + validate_candidate(state) + require( + read_json(directory / "candidate.json") + == { + key: state[key] + for key in ( + "repository", + "run_id", + "version", + "tag", + "baseline", + "date", + "candidate", + "tree", + ) + }, + "Retained candidate identity differs.", + ) + require( + (directory / "publisher.asc").read_bytes() + == signing.PUBLIC_KEY.read_bytes(), + "Retained certificate differs from launch certificate.", + ) + require( + package_hashes(directory / "dist", state["version"], signed=True) + == state["packages"], + "Retained distributions changed.", + ) + expected = { + "candidate.json", + "release.json", + "signed.bundle", + "publisher.asc", + } + expected |= {"dist/" + name for name in state["packages"]} + manifest = directory / "SHA256SUMS" + lines = manifest.read_text().splitlines() + require(len(lines) == len(expected), "Incomplete checksum manifest.") + manifest_paths = {Path(name).name: name for name in expected} + require( + len(manifest_paths) == len(expected), "Duplicate checksum basename." + ) + seen = set() + for line in lines: + digest, separator, name = line.partition(" ") + require( + separator and name in manifest_paths and name not in seen, + "Unexpected checksum entry.", + ) + require( + digest == sha256(directory / manifest_paths[name]), + "Checksum mismatch.", + ) + seen.add(name) + require(seen == set(manifest_paths), "Missing checksum entry.") + expected.add("SHA256SUMS") + signed_files = expected - {"publisher.asc"} + actual = { + p.relative_to(directory).as_posix() + for p in directory.rglob("*") + if p.is_file() + } + require( + actual == expected | {name + ".asc" for name in signed_files} + and not any(p.is_symlink() for p in directory.rglob("*")), + "Signed artifact set is incomplete.", + ) + with signing.public_environment() as env: + for name in sorted(signed_files): + signing.verify_status( + signing.gpg( + "--status-fd", + "1", + "--verify", + directory / (name + ".asc"), + directory / name, + env=env, + ) + ) + git("bundle", "verify", str(directory / "signed.bundle")) + git( + "fetch", + str(directory / "signed.bundle"), + SIGNED_REF + ":" + SIGNED_REF, + "refs/tags/" + state["tag"] + ":refs/tags/" + state["tag"], + ) + require( + git("rev-parse", SIGNED_REF) == state["commit"] + and git("rev-parse", SIGNED_REF + "^{tree}") == state["tree"] + and git("rev-parse", SIGNED_REF + "^") == state["baseline"] + and git("rev-parse", "refs/tags/" + state["tag"]) + == state["tag_object"], + "Retained signed history differs from tested candidate.", + ) + for field in ("%an", "%cn"): + require( + git("show", "-s", "--format=" + field, SIGNED_REF) + == "ScyllaDB Publisher", + "Unexpected Git identity.", + ) + for field in ("%ae", "%ce"): + require( + git("show", "-s", "--format=" + field, SIGNED_REF) + == "publish.code@scylladb.com", + "Unexpected Git identity.", + ) + signing.verify_commit(state["commit"]) + signing.verify_tag(state["tag"], state["commit"]) + return state + + +def missing_packages(expected, uploaded): + found = {} + for item in uploaded or []: + name = item["filename"] + require( + name in expected and name not in found, + "Unexpected PyPI distribution filename.", + ) + require( + item["digests"]["sha256"] == expected[name] + and not item.get("yanked"), + "PyPI distribution hash differs or file is " + "yanked; never overwrite " + "it.", + ) + found[name] = item + return sorted(set(expected) - found.keys()) + + +def publisher_ready(): + require( + gh("user")["login"] == "scylladb-publisher", + "Use the scylladb-publisher account credential.", + ) + team = gh("orgs/scylladb/teams/python-driver-publisher") + membership = gh( + "orgs/scylladb/teams/python-driver-publisher/me" + "mberships/scylladb-publisher" + ) + require( + membership["state"] == "active", + "Publisher must belong to its dedicated bypass team.", + ) + members = gh( + "orgs/scylladb/teams/python-driver-publisher/members?per_page=100" + ) + require( + [member["login"] for member in members] == ["scylladb-publisher"], + "The publisher bypass team must contain only scylladb-publisher.", + ) + rules = api("rules/branches/master") + pr_rules = [rule for rule in rules if rule["type"] == "pull_request"] + require( + pr_rules, + "Keep the master pull request requirement for other contributors.", + ) + for rule in pr_rules: + ruleset = api(f"rulesets/{rule['ruleset_id']}") + require( + any( + actor["actor_type"] == "Team" + and actor["actor_id"] == team["id"] + and actor["bypass_mode"] == "always" + for actor in ruleset["bypass_actors"] + ), + "Configure the publisher-only team bypass " + "before PyPI publication.", + ) + + +def prepublish(directory, pending): + state = verify_state(directory) + blockers() + publisher_ready() + require( + api("immutable-releases")["enabled"] is True, + "Enable repository release immutability before publication.", + ) + check_branch(state, state["commit"]) + remote_tag = remote_ref("refs/tags/" + state["tag"]) + require( + remote_tag in (None, state["tag_object"]), + "Remote release tag conflicts.", + ) + missing = missing_packages(state["packages"], pypi(state["version"])) + pending.mkdir(exist_ok=False) + for name in missing: + shutil.copyfile(directory / "dist" / name, pending / name) + output(missing=str(bool(missing)).lower(), commit=state["commit"]) + + +def verify_pypi(state): + for attempt in range(12): + missing = missing_packages(state["packages"], pypi(state["version"])) + if not missing: + return + time.sleep(5) + raise Invalid( + "PyPI files are incomplete; rerun this run to " + "upload only missing " + "files." + ) + + +def push(state): + require( + gh("user")["login"] == "scylladb-publisher", + "Use the scylladb-publisher account credential.", + ) + check_branch(state, state["commit"]) + require( + remote_ref("refs/tags/" + state["tag"]) in (None, state["tag_object"]), + "Remote tag conflicts.", + ) + git( + "-c", + "credential.helper=", + "-c", + "credential.helper=!gh auth git-credential", + "push", + "--atomic", + "https://github.com/" + REPOSITORY + ".git", + state["commit"] + ":refs/heads/master", + "refs/tags/" + state["tag"] + ":refs/tags/" + state["tag"], + ) + require( + remote_ref("refs/tags/" + state["tag"]) == state["tag_object"], + "Remote tag identity differs.", + ) + + +def github_release(directory, state): + require( + remote_ref("refs/tags/" + state["tag"]) == state["tag_object"], + "Remote tag identity differs.", + ) + existing = api("releases/tags/" + state["tag"], missing_ok=True) + if existing is None: + existing = gh( + "repos/" + REPOSITORY + "/releases", + "--method", + "POST", + "--input", + "-", + payload={ + "tag_name": state["tag"], + "target_commitish": state["commit"], + "name": state["tag"], + "body": f"ScyllaDB Python driver {state['version']}.\n" + "See CHANGELOG.rst for release notes. Verify " + "assets with publisher.asc and " + "SHA256SUMS.", + "draft": True, + "prerelease": False, + }, + ) + require( + existing["tag_name"] == state["tag"], "GitHub release tag differs." + ) + assets = {p.name: p for p in directory.rglob("*") if p.is_file()} + require( + len(assets) == len([p for p in directory.rglob("*") if p.is_file()]), + "Duplicate release asset basename.", + ) + endpoint = f"releases/{existing['id']}" + remote_assets = list(pages(endpoint + "/assets")) + require( + len(remote_assets) == len({item["name"] for item in remote_assets}), + "Duplicate remote assets.", + ) + for item in remote_assets: + require(item["name"] in assets, "Unexpected GitHub release asset.") + # Digest is mandatory: never replace or accept an unverified existing + # asset. + require( + item.get("digest") == "sha256:" + sha256(assets[item["name"]]), + "GitHub asset hash differs.", + ) + missing = sorted(set(assets) - {item["name"] for item in remote_assets}) + if existing["draft"]: + for name in missing: + command( + "gh", + "release", + "upload", + state["tag"], + str(assets[name]), + "--repo", + REPOSITORY, + ) + uploaded = list(pages(endpoint + "/assets")) + require( + len(uploaded) == len(assets) + and all( + item["name"] in assets + and item.get("digest") + == "sha256:" + sha256(assets[item["name"]]) + for item in uploaded + ), + "Draft release assets are incomplete or changed.", + ) + gh( + "repos/" + REPOSITORY + "/" + endpoint, + "--method", + "PATCH", + "--input", + "-", + payload={"draft": False}, + ) + existing = api(endpoint) + require( + not missing or existing["draft"] is False, + "Published release has missing assets.", + ) + require( + existing.get("immutable") is True and existing["draft"] is False, + "GitHub release must be immutable before docs deployment.", + ) + # Recheck assets after publishing, including already-published recovery. + uploaded = list(pages(endpoint + "/assets")) + require( + {item["name"] for item in uploaded} == set(assets) + and all( + item.get("digest") == "sha256:" + sha256(assets[item["name"]]) + for item in uploaded + ), + "Published release assets differ.", + ) + require( + remote_ref("refs/tags/" + state["tag"]) == state["tag_object"], + "Remote tag identity differs.", + ) + + +def finish(directory): + state = verify_state(directory) + verify_pypi(state) + blockers() + push(state) + github_release(directory, state) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "operation", + choices=[ + "prepare", + "restore", + "collect", + "sign", + "verify", + "prepublish", + "finish", + ], + ) + parser.add_argument( + "--candidate", + type=Path, + default=Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "candidate", + ) + parser.add_argument( + "--state", + type=Path, + default=Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "signed-state", + ) + parser.add_argument( + "--builds", + type=Path, + default=Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "builds", + ) + parser.add_argument( + "--pending", + type=Path, + default=Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "pending", + ) + parser.add_argument("--version", default=os.environ.get("RELEASE_VERSION")) + parser.add_argument("--require-retained", action="store_true") + args = parser.parse_args() + try: + if args.operation == "prepare": + prepare(args.candidate, args.version) + elif args.operation == "restore": + restore_state(args.state, args.require_retained) + elif args.operation == "collect": + collect(args.builds, args.pending, args.version) + elif args.operation == "sign": + create_signed(args.candidate, args.builds, args.state) + elif args.operation == "verify": + verify_state(args.state) + elif args.operation == "prepublish": + prepublish(args.state, args.pending) + elif args.operation == "finish": + finish(args.state) + return 0 + except (Invalid, signing.SigningError) as error: + print(str(error), file=sys.stderr) + except Exception: + print( + "Release operation failed; output withheld. " + "Inspect retained artifacts before " + "recovery.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/signing.py b/scripts/release/signing.py new file mode 100644 index 0000000000..8eab063b21 --- /dev/null +++ b/scripts/release/signing.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +"""Configure and verify the repository's protected release signing subkey.""" + +from contextlib import contextmanager +import os +from pathlib import Path +import shlex +import signal +import subprocess +import sys +import tempfile +import time + +PRIMARY = "786703A4F7AC1E986C7CFB2318ED6CC7EBE7C5F9" +SIGNING = "7C0A5E8366A579484D1D352C2EC9B987C56EF1CA" +PUBLIC_KEY = Path(__file__).with_name("publisher.asc") +TERMINATION_GRACE = 2 + + +class SigningError(ValueError): + pass + + +def command_operation(command): + # Only fixed labels enter diagnostics; argv and child output may contain + # secrets. + if command[0] == "gpg": + for flag, label in [ + ("--import", "key import"), + ("--detach-sign", "signature creation"), + ("--verify", "signature verification"), + ("--list-secret-keys", "secret-key inspection"), + ]: + if flag in command: + return "GPG " + label + return "GPG operation" + if command[0] == "gpgconf": + return "GPG agent shutdown" + if command[0] == "git": + for subcommand in [ + "verify-commit", + "verify-tag", + "check-ref-format", + "cat-file", + "rev-parse", + ]: + if subcommand in command: + return "Git " + subcommand + return "Git operation" + return "command" + + +def invoke(command, **kwargs): + operation = command_operation(command) + try: + return subprocess.run(command, **kwargs) + except subprocess.TimeoutExpired: + raise SigningError(operation + " timed out.") from None + except OSError: + raise SigningError( + operation + " could not start (OS error)." + ) from None + + +def run(command, env=None, **kwargs): + result = invoke( + command, env=env, capture_output=True, text=True, timeout=60, **kwargs + ) + if result.returncode: + raise SigningError( + f"{command_operation(command)} failed " + f"(exit status {result.returncode})." + ) + # Git writes --raw verification status to stderr; ordinary callers parse + # stdout. + if ( + command[0] == "git" + and "--raw" in command + and any( + operation in command + for operation in ("verify-commit", "verify-tag") + ) + ): + return result.stdout + result.stderr + return result.stdout + + +def gpg(*args, env=None): + return run( + [ + "gpg", + "--no-options", + "--batch", + "--no-tty", + "--no-auto-key-retrieve", + *map(str, args), + ], + env, + ) + + +def verify_status(status): + records = [ + line.split() + for line in status.splitlines() + if line.startswith("[GNUPG:] ") + ] + signatures = [ + row for row in records if len(row) > 1 and row[1] == "VALIDSIG" + ] + good = [row for row in records if len(row) > 1 and row[1] == "GOODSIG"] + rejected = { + "EXPKEYSIG", + "REVKEYSIG", + "EXPSIG", + "BADSIG", + "ERRSIG", + "FAILURE", + "ERROR", + "KEYEXPIRED", + "KEYREVOKED", + "SIGEXPIRED", + } + if ( + len(signatures) != 1 + or len(signatures[0]) != 12 + or signatures[0][2] != SIGNING + or signatures[0][-1] != PRIMARY + or len(good) != 1 + or len(good[0]) < 3 + or good[0][2] != SIGNING[-16:] + or any(len(row) > 1 and row[1] in rejected for row in records) + ): + raise SigningError( + "Signature does not match the repository's release subkey." + ) + + +def protected_subkey(env): + rows = [ + line.split(":") + for line in gpg( + "--with-colons", "--with-keygrip", "--list-secret-keys", env=env + ).splitlines() + ] + fingerprints = [row[9] for row in rows if row[0] == "fpr"] + keys = [row for row in rows if row[0] in ("sec", "ssb")] + grips = [row[9] for row in rows if row[0] == "grp"] + if ( + fingerprints != [PRIMARY, SIGNING] + or len(keys) != 2 + or len(grips) != 2 + or keys[0][14] != "#" + or keys[1][14] != "+" + ): + raise SigningError( + "Import only this repository's signing-private.asc release subkey." + ) + keydir = Path(env["GNUPGHOME"]) / "private-keys-v1.d" + if ( + keydir / (grips[0] + ".key") + ).exists() or b"(protected-private-key" not in ( + keydir / (grips[1] + ".key") + ).read_bytes(): + raise SigningError( + "Release signing requires a protected subkey " + "without the primary " + "secret." + ) + + +def write_private(path, text, executable=False): + with open( + path, "x", opener=lambda name, flags: os.open(name, flags, 0o600) + ) as output: + output.write(text) + if executable: + path.chmod(0o700) + + +@contextmanager +def public_environment(): + with tempfile.TemporaryDirectory(prefix="release-public-") as temp: + env = {**os.environ, "GNUPGHOME": temp} + try: + gpg("--import", PUBLIC_KEY, env=env) + yield env + finally: + run(["gpgconf", "--homedir", temp, "--kill", "gpg-agent"]) + + +def passphrase_value(env): + value = env.get( + "RELEASE_GPG_PASSPHRASE", + env.get("MAVEN_GPG_PASSPHRASE", env.get("PGP_PASSPHRASE", "")), + ) + if "RELEASE_GPG_PASSPHRASE_FILE" in env: + value = ( + Path(env["RELEASE_GPG_PASSPHRASE_FILE"]) + .read_text() + .removesuffix("\n") + ) + if ( + not value + or "\n" in value + or "\r" in value + or len(value.encode()) > 1024 + ): + raise SigningError( + "Release signing requires a nonempty single-line passphrase." + ) + return value + + +def credential_gpg(args, payload=None, capture=True, env=None): + env = os.environ if env is None else env + # A separate pipe preserves stdin for Git's payload. Its descriptor, never + # its contents, appears in argv; pass_fds deliberately survives only to + # gpg. + reader, writer = os.pipe() + try: + try: + os.write(writer, (passphrase_value(env) + "\n").encode()) + finally: + os.close(writer) + command = [ + "gpg", + "--no-options", + "--batch", + "--no-tty", + "--no-auto-key-retrieve", + "--pinentry-mode", + "loopback", + "--passphrase-fd", + str(reader), + *map(str, args), + ] + if capture: + return run(command, env=env, input=payload, pass_fds=(reader,)) + return invoke( + command, input=payload, pass_fds=(reader,), env=env, timeout=60 + ).returncode + finally: + os.close(reader) + + +class SessionSignals: + def __init__(self): + self.signum = None + + def __call__(self, signum, frame): + # Recording cannot interrupt resource acquisition or teardown midway. + if self.signum is None: + self.signum = signum + + def check(self): + if self.signum is not None: + raise SystemExit(128 + self.signum) + + +@contextmanager +def session_signals(): + signals = (signal.SIGTERM, signal.SIGHUP, signal.SIGINT) + previous = {signum: signal.getsignal(signum) for signum in signals} + current = previous[signal.SIGTERM] + if isinstance(current, SessionSignals) and all( + handler is current for handler in previous.values() + ): + yield current + return + interrupted = SessionSignals() + try: + for signum in signals: + signal.signal(signum, interrupted) + yield interrupted + finally: + for signum, handler in previous.items(): + signal.signal(signum, handler) + interrupted.check() + + +def execute(command, env): + with session_signals() as interrupted: + process = None + try: + interrupted.check() + try: + process = subprocess.Popen( + command, env=env, start_new_session=True + ) + except OSError: + raise SigningError( + command_operation(command) + " could not start (OS error)." + ) from None + while True: + interrupted.check() + try: + status = process.wait(timeout=0.1) + except subprocess.TimeoutExpired: + continue + interrupted.check() + return status if status >= 0 else 128 - status + except BaseException as error: + if process is None: + raise + signum = ( + error.code - 128 + if isinstance(error, SystemExit) + and isinstance(error.code, int) + else signal.SIGTERM + ) + if signum not in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + signum = signal.SIGTERM + try: + os.killpg(process.pid, signum) + except ProcessLookupError: + process.wait() + raise error + deadline = time.monotonic() + TERMINATION_GRACE + try: + process.wait(timeout=TERMINATION_GRACE) + except subprocess.TimeoutExpired: + # A child that ignores graceful termination is killed below. + pass + # Descendants may outlive their parent, so allow the full grace + # period. + time.sleep(max(0, deadline - time.monotonic())) + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + # The process group may have exited during graceful teardown. + pass + process.wait() + raise + + +@contextmanager +def signing_session(): + env = dict(os.environ) + passphrase_value(env) + private = env.pop("RELEASE_GPG_PRIVATE_KEY", "") + if "RELEASE_GPG_PRIVATE_KEY_FILE" in env: + private = Path(env.pop("RELEASE_GPG_PRIVATE_KEY_FILE")).read_text() + if not private: + raise SigningError( + "Release signing requires a protected signing-subkey export." + ) + with ( + session_signals() as interrupted, + tempfile.TemporaryDirectory( + prefix="release-signing-", dir=env.get("RUNNER_TEMP") + ) as temp, + ): + directory = Path(temp) + signing_home = directory / "keyring" + signing_home.mkdir(mode=0o700) + env["GNUPGHOME"] = str(signing_home) + try: + credential_gpg(["--import"], payload=private, env=env) + interrupted.check() + protected_subkey(env) + wrapper = directory / "gpg-wrapper" + write_private( + wrapper, + "#!/bin/sh\nexec python3 " + + shlex.quote(str(Path(__file__).resolve())) + + ' gpg-program "$@"\n', + executable=True, + ) + # Configuration is inherited only by the command and its children. + # Runtime entries override checkout settings without changing + # files. + settings = { + "user.name": "ScyllaDB Publisher", + "user.email": "publish.code@scylladb.com", + "user.signingkey": SIGNING + "!", + "commit.gpgsign": "true", + "tag.gpgSign": "true", + "gpg.format": "openpgp", + "gpg.program": str(wrapper), + } + count = int(env.get("GIT_CONFIG_COUNT", "0")) + for index, (name, value) in enumerate( + settings.items(), start=count + ): + env[f"GIT_CONFIG_KEY_{index}"] = name + env[f"GIT_CONFIG_VALUE_{index}"] = value + env["GIT_CONFIG_COUNT"] = str(count + len(settings)) + artifact = directory / "preflight" + write_private(artifact, "Repository release signing preflight\n") + # Cached credentials must not satisfy the passphrase check. + run( + [ + "gpgconf", + "--homedir", + str(signing_home), + "--kill", + "gpg-agent", + ] + ) + credential_gpg( + ["--local-user", SIGNING + "!", "--detach-sign", artifact], + env=env, + ) + # First use materializes openpgp-native imports. Before then even + # an + # unprotected export can appear as a protected-private-key to the + # agent. + protected_subkey(env) + with public_environment() as verify_env: + verify_status( + gpg( + "--status-fd", + "1", + "--verify", + str(artifact) + ".sig", + artifact, + env=verify_env, + ) + ) + interrupted.check() + yield env + finally: + run( + [ + "gpgconf", + "--homedir", + str(signing_home), + "--kill", + "gpg-agent", + ] + ) + + +def verify_git(kind, ref): + with public_environment() as env: + verify_status( + run( + [ + "git", + "-c", + "gpg.program=gpg", + "verify-" + kind, + "--raw", + ref, + ], + env, + ) + ) + + +def verify_release(tag, baseline=None): + baseline = baseline or os.environ.get("RELEASE_BASE_COMMIT") + if not baseline: + raise SigningError( + "Release verification requires the " + "pre-prepare RELEASE_BASE_COMMIT or an " + "explicit baseline." + ) + run(["git", "merge-base", "--is-ancestor", baseline, "HEAD"]) + commits = run(["git", "rev-list", baseline + "..HEAD"]).splitlines() + if not commits: + raise SigningError("No release commits were created.") + with public_environment() as env: + for commit in commits: + verify_status( + run( + [ + "git", + "-c", + "gpg.program=gpg", + "verify-commit", + "--raw", + commit, + ], + env, + ) + ) + ref = "refs/tags/" + tag + run(["git", "check-ref-format", ref]) + if run(["git", "cat-file", "-t", ref]).strip() != "tag": + raise SigningError("Release tags must be signed annotated tags.") + if run(["git", "rev-parse", ref + "^{commit}"]).strip() not in commits: + raise SigningError( + "Release tag does not point to a newly signed release commit." + ) + verify_status( + run( + ["git", "-c", "gpg.program=gpg", "verify-tag", "--raw", ref], + env, + ) + ) + + +def verify_commit(commit): + verify_git("commit", commit) + + +def verify_tag(tag, commit=None): + ref = "refs/tags/" + tag + run(["git", "check-ref-format", ref]) + if run(["git", "cat-file", "-t", ref]).strip() != "tag": + raise SigningError("Release tags must be signed annotated tags.") + if ( + commit is not None + and run(["git", "rev-parse", ref + "^{commit}"]).strip() != commit + ): + raise SigningError("Release tag points to an unexpected commit.") + verify_git("tag", ref) + + +def sign_files(files, signing_env=None): + if not files: + raise SigningError("At least one release artifact is required.") + if ( + signing_env is None + and ( + os.environ.get("RELEASE_GPG_PRIVATE_KEY") + or "RELEASE_GPG_PRIVATE_KEY_FILE" in os.environ + ) + and any( + not Path(str(Path(filename).resolve()) + ".asc").exists() + for filename in files + ) + ): + with signing_session() as session: + sign_files(files, session) + return + with session_signals() as interrupted, public_environment() as env: + for filename in files: + interrupted.check() + artifact = Path(filename).resolve() + if not artifact.is_file() or artifact.suffix == ".asc": + raise SigningError("Expected an unsigned release artifact.") + # Existing signatures are immutable: never overwrite them on a + # rerun. + signature = Path(str(artifact) + ".asc") + if not signature.exists(): + credential_gpg( + [ + "--local-user", + SIGNING + "!", + "--armor", + "--detach-sign", + "--output", + signature, + artifact, + ], + env=signing_env, + ) + interrupted.check() + verify_status( + gpg( + "--status-fd", + "1", + "--verify", + signature, + artifact, + env=env, + ) + ) + + +def main(): + try: + if len(sys.argv) > 1 and sys.argv[1] == "gpg-program": + # Git requires stdout/stderr byte-for-byte; do not print a success + # banner. + return credential_gpg(sys.argv[2:], capture=False) + if len(sys.argv) in (3, 4) and sys.argv[1] == "verify-release": + verify_release(*sys.argv[2:]) + elif len(sys.argv) == 3 and sys.argv[1] == "verify-commit": + verify_commit(sys.argv[2]) + elif len(sys.argv) in (3, 4) and sys.argv[1] == "verify-tag": + verify_tag(*sys.argv[2:]) + elif len(sys.argv) > 2 and sys.argv[1] == "sign-files": + sign_files(sys.argv[2:]) + elif len(sys.argv) > 2 and sys.argv[1] == "exec": + with signing_session() as env: + return execute(sys.argv[2:], env) + else: + raise SigningError( + "Expected verify-release TAG [BASELINE], " + "verify-commit COMMIT, verify-tag TAG " + "[COMMIT], sign-files FILE..., or exec " + "COMMAND..." + ) + print("Release signing " + sys.argv[1] + " passed.") + return 0 + except SigningError as error: + print(str(error), file=sys.stderr) + except Exception as error: + category = ( + "OS error" + if isinstance(error, OSError) + else "invalid value" + if isinstance(error, ValueError) + else "internal error" + ) + operation = ( + sys.argv[1] + if len(sys.argv) > 1 + and sys.argv[1] + in ( + "gpg-program", + "verify-release", + "verify-commit", + "verify-tag", + "sign-files", + "exec", + ) + else "command" + ) + print( + f"Release signing {operation} failed ({category}); " + f"values withheld.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/test_publication.py b/scripts/release/test_publication.py new file mode 100644 index 0000000000..2fbc0a6ce2 --- /dev/null +++ b/scripts/release/test_publication.py @@ -0,0 +1,317 @@ +"Exercise partial publication recovery without writing to external services." + +from contextlib import contextmanager +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import release + + +class PublicationTests(unittest.TestCase): + @contextmanager + def github(self, published=False, immutable=True, partial=False): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + for name in ( + "package.whl", + "package.whl.asc", + "SHA256SUMS", + "SHA256SUMS.asc", + "publisher.asc", + ): + (directory / name).write_text(name) + state = { + "tag": "3.29.14-scylla", + "commit": "commit", + "tag_object": "signed-tag", + "version": "3.29.14", + } + existing = { + "id": 1, + "tag_name": state["tag"], + "draft": not published, + "immutable": immutable, + } + assets = [ + {"name": p.name, "digest": "sha256:" + release.sha256(p)} + for p in directory.iterdir() + ] + uploaded = assets[:1] if partial else assets.copy() + events = [] + + def api(endpoint, **kwargs): + events.append(("api", endpoint)) + if endpoint == "releases/tags/" + state["tag"]: + return existing.copy() + if endpoint == "releases/1": + return existing.copy() + raise AssertionError(endpoint) + + def gh(*args, **kwargs): + events.append(("gh", kwargs["payload"])) + self.assertEqual(kwargs["payload"], {"draft": False}) + existing["draft"] = False + + def command(*args, **kwargs): + events.append(("upload", args)) + name = Path(args[4]).name + uploaded.append( + { + "name": name, + "digest": "sha256:" + release.sha256(directory / name), + } + ) + + with ( + patch.object(release, "remote_ref", return_value="signed-tag"), + patch.object(release, "api", side_effect=api), + patch.object(release, "gh", side_effect=gh), + patch.object( + release, + "pages", + side_effect=lambda endpoint: iter(uploaded), + ), + patch.object(release, "command", side_effect=command), + ): + yield directory, state, existing, uploaded, events + + def test_partial_draft_upload_only_missing_assets_then_publish(self): + with self.github(partial=True) as ( + directory, + state, + existing, + uploaded, + events, + ): + original = uploaded[0].copy() + release.github_release(directory, state) + self.assertEqual(uploaded[0], original) + self.assertEqual( + len([event for event in events if event[0] == "upload"]), 4 + ) + self.assertFalse(existing["draft"]) + self.assertEqual( + [event[0] for event in events][-2:], ["gh", "api"] + ) + + def test_published_immutable_release_is_verification_only(self): + with self.github(published=True) as ( + directory, + state, + existing, + uploaded, + events, + ): + release.github_release(directory, state) + self.assertTrue(all(event[0] == "api" for event in events)) + + def test_missing_published_asset_or_mutable_release_rejected(self): + for partial, immutable in ((True, True), (False, False)): + with self.github( + published=True, partial=partial, immutable=immutable + ) as ( + directory, + state, + existing, + uploaded, + events, + ): + with self.assertRaises(release.Invalid): + release.github_release(directory, state) + self.assertTrue(all(event[0] == "api" for event in events)) + + def test_changed_existing_signature_rejected_without_replacement(self): + with self.github() as (directory, state, existing, uploaded, events): + signature = next( + a for a in uploaded if a["name"] == "package.whl.asc" + ) + uploaded[:] = [signature] + signature["digest"] = "sha256:changed" + with self.assertRaisesRegex(release.Invalid, "hash differs"): + release.github_release(directory, state) + self.assertTrue(all(event[0] == "api" for event in events)) + + def test_remote_tag_conflict_stops_github_publication(self): + with ( + patch.object(release, "remote_ref", return_value="other-tag"), + patch.object(release, "api") as api, + ): + with self.assertRaises(release.Invalid): + release.github_release( + Path("/unused"), + {"tag": "3.29.14-scylla", "tag_object": "signed-tag"}, + ) + api.assert_not_called() + + def test_prepublication_missing_uploads_are_exact_retained_bytes(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) / "signed" + (directory / "dist").mkdir(parents=True) + (directory / "dist/a.whl").write_bytes(b"original") + (directory / "dist/b.whl").write_bytes(b"already uploaded") + state = { + "version": "3.29.14", + "commit": "signed", + "tag": "3.29.14-scylla", + "tag_object": "tag", + "packages": { + p.name: release.sha256(p) + for p in (directory / "dist").iterdir() + }, + } + uploaded = [ + { + "filename": "b.whl", + "digests": {"sha256": state["packages"]["b.whl"]}, + } + ] + pending = Path(temp) / "pending" + with ( + patch.object(release, "verify_state", return_value=state), + patch.object(release, "blockers"), + patch.object(release, "publisher_ready"), + patch.object(release, "check_branch"), + patch.object(release, "api", return_value={"enabled": True}), + patch.object(release, "remote_ref", return_value=None), + patch.object(release, "pypi", return_value=uploaded), + patch.object(release, "output") as output, + ): + release.prepublish(directory, pending) + self.assertEqual([p.name for p in pending.iterdir()], ["a.whl"]) + self.assertEqual((pending / "a.whl").read_bytes(), b"original") + output.assert_called_once_with(missing="true", commit="signed") + + def test_branch_advancement_stops_before_upload_directory_exists(self): + with tempfile.TemporaryDirectory() as temp: + pending = Path(temp) / "pending" + with ( + patch.object( + release, "verify_state", return_value={"commit": "signed"} + ), + patch.object(release, "blockers"), + patch.object(release, "publisher_ready"), + patch.object(release, "api", return_value={"enabled": True}), + patch.object( + release, + "check_branch", + side_effect=release.Invalid("master advanced"), + ), + patch.object(release, "pypi") as pypi, + ): + with self.assertRaises(release.Invalid): + release.prepublish(Path("/unused"), pending) + self.assertFalse(pending.exists()) + pypi.assert_not_called() + + def test_pypi_verification_conflict_stops_immediately(self): + with ( + patch.object( + release, + "pypi", + return_value=[ + {"filename": "a.whl", "digests": {"sha256": "other"}} + ], + ), + patch.object(release.time, "sleep") as sleep, + ): + with self.assertRaises(release.Invalid): + release.verify_pypi( + {"version": "3.29.14", "packages": {"a.whl": "expected"}} + ) + sleep.assert_not_called() + + def test_push_is_atomic_and_has_no_force_or_tag_recreation(self): + state = { + "baseline": "baseline", + "commit": "signed", + "tag": "3.29.14-scylla", + "tag_object": "signed-tag", + } + with ( + patch.object( + release, "gh", return_value={"login": "scylladb-publisher"} + ), + patch.object(release, "check_branch"), + patch.object( + release, "remote_ref", side_effect=["signed-tag", "signed-tag"] + ), + patch.object(release, "git") as git, + ): + release.push(state) + args = git.call_args.args + self.assertIn("--atomic", args) + self.assertNotIn("--force", args) + self.assertEqual( + args[-2:], + ( + "signed:refs/heads/master", + "refs/tags/3.29.14-scylla:refs/tags/3.29.14-scylla", + ), + ) + + def test_publisher_bypass_is_specific_and_pr_rule_is_preserved(self): + for members, actors, accepted in [ + ( + [{"login": "scylladb-publisher"}], + [ + { + "actor_type": "Team", + "actor_id": 99, + "bypass_mode": "always", + } + ], + True, + ), + ( + [{"login": "scylladb-publisher"}, {"login": "contributor"}], + [ + { + "actor_type": "Team", + "actor_id": 99, + "bypass_mode": "always", + } + ], + False, + ), + ([{"login": "scylladb-publisher"}], [], False), + ]: + with ( + patch.object( + release, + "gh", + side_effect=[ + {"login": "scylladb-publisher"}, + {"id": 99}, + {"state": "active"}, + members, + ], + ), + patch.object( + release, + "api", + side_effect=[ + [{"type": "pull_request", "ruleset_id": 123}], + {"bypass_actors": actors}, + ], + ), + ): + if accepted: + release.publisher_ready() + else: + with self.assertRaises(release.Invalid): + release.publisher_ready() + + def test_wrong_publisher_account_cannot_push(self): + with ( + patch.object(release, "gh", return_value={"login": "contributor"}), + patch.object(release, "git") as git, + ): + with self.assertRaises(release.Invalid): + release.push({}) + git.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/test_release.py b/scripts/release/test_release.py new file mode 100644 index 0000000000..f03b3cf528 --- /dev/null +++ b/scripts/release/test_release.py @@ -0,0 +1,528 @@ +""" +Release orchestration tests: real archives/Git/signatures, mocked +publication. +""" + +from contextlib import contextmanager +from io import BytesIO +import os +from pathlib import Path +import shutil +import tarfile +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +import release +import signing +import test_signing + +ROOT = Path(__file__).resolve().parents[2] + + +def metadata_fixture(root): + files = { + "cassandra/__init__.py": "__version_info__ = (3, 29, 13)\n", + "docs/conf.py": ( + "TAGS = [\n '3.29.13-scylla',\n]\nLATEST_VERSIO" + "N = '3.29.13-scylla'\n" + ), + "docs/installation.rst": 'It should print something like "3.29.13".\n', + "CHANGELOG.rst": ( + "Unreleased\n==========\n\nBug Fixes\n---------\n* " + "Fixed an issue.\n\n3.29.13\n=======\n2026-11-02\n" + ), + } + for name, content in files.items(): + target = root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + + +def archives(directory, version="3.29.14"): + directory.mkdir(parents=True) + metadata = ( + f"Metadata-Version: 2.4\nName: scylla-driver\n" + f"Version: {version}\n" + ).encode() + wheel = ( + directory + / f"scylla_driver-{version}-cp313-cp313-manylinux_2_28_x86_64.whl" + ) + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + f"scylla_driver-{version}.dist-info/METADATA", metadata + ) + source = directory / f"scylla_driver-{version}.tar.gz" + with tarfile.open(source, "w:gz") as archive: + member = tarfile.TarInfo(f"scylla_driver-{version}/PKG-INFO") + member.size = len(metadata) + archive.addfile(member, BytesIO(metadata)) + return wheel, source + + +class MetadataTests(unittest.TestCase): + @contextmanager + def source(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + metadata_fixture(root) + yield root + + def test_release_metadata(self): + with self.source() as root: + original = (root / "CHANGELOG.rst").read_text() + release.prepare_metadata(root, "3.29.14", "2026-11-05") + self.assertIn( + "__version_info__ = (3, 29, 14)", + (root / "cassandra/__init__.py").read_text(), + ) + changelog = (root / "CHANGELOG.rst").read_text() + self.assertTrue( + changelog.startswith( + "Unreleased\n==========\n\n3.29.14\n=======\n2026-11-05\n" + ) + ) + self.assertIn( + original.split("3.29.13\n")[0].split("Bug Fixes")[1], changelog + ) + self.assertIn( + "LATEST_VERSION = '3.29.14-scylla'", + (root / "docs/conf.py").read_text(), + ) + self.assertIn( + " '3.29.14-scylla',\n]", (root / "docs/conf.py").read_text() + ) + self.assertIn( + '"3.29.14"', (root / "docs/installation.rst").read_text() + ) + + def test_invalid_and_nonincreasing_versions(self): + for version in ( + "3.29.13", + "3.28.0", + "3.29.14rc1", + "3.29", + "03.29.14", + "3.29.14\nx", + ): + with ( + self.subTest(version=version), + self.source() as root, + self.assertRaises(release.Invalid), + ): + release.prepare_metadata(root, version, "2026-11-05") + + def test_changelog_missing_or_empty(self): + for text in ( + "No unreleased entries", + "Unreleased\n==========\n\n3.29.13\n=======\nOld\n", + ): + with self.source() as root: + (root / "CHANGELOG.rst").write_text(text) + with self.assertRaises(release.Invalid): + release.prepare_metadata(root, "3.29.14", "2026-11-05") + + def test_package_metadata_and_filename(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) / "dist" + archives(directory) + self.assertEqual( + len(release.package_hashes(directory, "3.29.14")), 2 + ) + with self.assertRaises(release.Invalid): + release.package_hashes(directory, "3.29.15") + (directory / "unexpected.txt").write_text("unexpected") + with self.assertRaises(release.Invalid): + release.package_hashes(directory, "3.29.14") + + def test_missing_platform_artifact(self): + with tempfile.TemporaryDirectory() as temp: + builds = Path(temp) / "builds" + builds.mkdir() + with self.assertRaisesRegex(release.Invalid, "platform"): + release.collect(builds, Path(temp) / "dist", "3.29.14") + + +class GuardTests(unittest.TestCase): + def test_blockers_past_first_page(self): + first = [{"pull_request": {}, "html_url": "pr"}] * 100 + second = [ + { + "html_url": ( + "https://github.com/scylladb/python-driver/issues/123" + ) + } + ] + with patch.object(release, "api", side_effect=[first, second]) as api: + with self.assertRaisesRegex(release.Invalid, "/123"): + release.blockers() + self.assertIn("page=2", api.call_args_list[-1].args[0]) + + def test_pagination_failure_is_not_absence(self): + with patch.object( + release, "api", side_effect=release.Invalid("Network failed") + ): + with self.assertRaises(release.Invalid): + release.blockers() + + def test_branch_advancement_and_recovery(self): + for branch, signed, accepted in [ + ("baseline", None, True), + ("release", "release", True), + ("later", "release", False), + (None, None, False), + ]: + with ( + self.subTest(branch=branch), + patch.object(release, "remote_ref", return_value=branch), + ): + if accepted: + release.check_branch({"baseline": "baseline"}, signed) + else: + with self.assertRaisesRegex( + release.Invalid, "manual recovery" + ): + release.check_branch({"baseline": "baseline"}, signed) + + def test_partial_upload_and_conflicts(self): + expected = {"a.whl": "aaa", "b.whl": "bbb"} + self.assertEqual( + release.missing_packages(expected, []), ["a.whl", "b.whl"] + ) + uploaded = [{"filename": "a.whl", "digests": {"sha256": "aaa"}}] + self.assertEqual( + release.missing_packages(expected, uploaded), ["b.whl"] + ) + for files in ( + [{"filename": "a.whl", "digests": {"sha256": "changed"}}], + [{"filename": "extra.whl", "digests": {"sha256": "aaa"}}], + uploaded * 2, + [dict(uploaded[0], yanked=True)], + ): + with self.subTest(files=files), self.assertRaises(release.Invalid): + release.missing_packages(expected, files) + + def test_restore_expired_or_duplicate_artifacts_rejected(self): + for artifacts in ( + [{"name": "release-state", "expired": True}], + [{"name": "release-state", "expired": False}] * 2, + ): + with ( + patch.dict(os.environ, GITHUB_RUN_ID="12"), + patch.object(release, "pages", return_value=iter(artifacts)), + patch.object(release, "command") as command, + ): + with self.assertRaises(release.Invalid): + release.restore("release-state", Path("/unused")) + command.assert_not_called() + + def test_missing_artifact_and_api_error_distinguished(self): + with ( + patch.dict(os.environ, GITHUB_RUN_ID="12"), + patch.object(release, "pages", return_value=iter([])), + ): + self.assertFalse(release.restore("release-state", Path("/unused"))) + with ( + patch.dict(os.environ, GITHUB_RUN_ID="12"), + patch.object( + release, "pages", side_effect=release.Invalid("denied") + ), + ): + with self.assertRaises(release.Invalid): + release.restore("release-state", Path("/unused")) + + def test_restore_state_rechecks_checkpoint_on_failed_job_rerun(self): + for required in (False, True): + with ( + self.subTest(required=required), + patch.object(release, "restore", return_value=True) as restore, + patch.object(release, "output") as output, + ): + release.restore_state(Path("/state"), required) + restore.assert_called_once_with( + "release-state", Path("/state") + ) + output.assert_called_once_with(retained="true") + + def test_fresh_signing_requires_an_absent_checkpoint(self): + with ( + patch.object(release, "restore", return_value=False), + patch.object(release, "output") as output, + ): + release.restore_state(Path("/state")) + output.assert_called_once_with(retained="false") + + def test_expected_checkpoint_missing_cannot_trigger_new_signatures(self): + with ( + patch.object(release, "restore", return_value=False), + patch.object(release, "output") as output, + ): + with self.assertRaisesRegex(release.Invalid, "manual recovery"): + release.restore_state(Path("/state"), require_retained=True) + output.assert_not_called() + + def test_checkpoint_lookup_failure_cannot_trigger_new_signatures(self): + with ( + patch.object( + release, "restore", side_effect=release.Invalid("denied") + ), + patch.object(release, "output") as output, + ): + with self.assertRaisesRegex(release.Invalid, "denied"): + release.restore_state(Path("/state")) + output.assert_not_called() + + def test_restore_cli_preserves_the_required_checkpoint_guard(self): + with ( + patch.object( + release.sys, + "argv", + [ + "release.py", + "restore", + "--state", + "/state", + "--require-retained", + ], + ), + patch.object(release, "restore_state") as restore, + ): + self.assertEqual(release.main(), 0) + restore.assert_called_once_with(Path("/state"), True) + + def test_preflight_blocker_stops_upload_preparation(self): + with ( + patch.object(release, "verify_state", return_value={}), + patch.object( + release, "blockers", side_effect=release.Invalid("blocked") + ), + patch.object(release, "pypi") as pypi, + ): + with self.assertRaises(release.Invalid): + release.prepublish(Path("/unused"), Path("/unused")) + pypi.assert_not_called() + + def test_finish_orders_pypi_git_release(self): + state = {"commit": "signed"} + for failing in (None, "verify_pypi", "push", "github_release"): + seen = [] + + def operation(name): + def run(*args): + seen.append(name) + if name == failing: + raise release.Invalid("external failure") + + return run + + with ( + patch.object(release, "verify_state", return_value=state), + patch.object( + release, + "verify_pypi", + side_effect=operation("verify_pypi"), + ), + patch.object( + release, "blockers", side_effect=operation("blockers") + ), + patch.object(release, "push", side_effect=operation("push")), + patch.object( + release, + "github_release", + side_effect=operation("github_release"), + ), + ): + if failing: + with self.assertRaises(release.Invalid): + release.finish(Path("/unused")) + else: + release.finish(Path("/unused")) + order = ["verify_pypi", "blockers", "push", "github_release"] + self.assertEqual( + seen, + order[: order.index(failing) + 1] if failing else order, + ) + + +# Keep expensive disposable key generation in the existing signing fixture. +class SignedStateTests(test_signing.SigningFixture): + @contextmanager + def prepared(self): + with self.repository() as directory: + metadata_fixture(Path.cwd()) + release.git("add", ".") + release.git( + "-c", + "user.name=Initial", + "-c", + "user.email=initial@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "Metadata fixture", + ) + baseline = release.git("rev-parse", "HEAD") + os.environ.update( + GITHUB_SHA=baseline, + GITHUB_RUN_ID="123", + GITHUB_REF="refs/heads/master", + GITHUB_REPOSITORY=release.REPOSITORY, + GITHUB_OUTPUT=str(directory / "outputs"), + ) + candidate = directory / "candidate" + with ( + patch.object(release, "blockers"), + patch.object(release, "pypi", return_value=None), + patch.object(release, "restore", return_value=False), + patch.object( + release, + "remote_ref", + side_effect=lambda ref: baseline + if ref.endswith("/master") + else None, + ), + ): + release.prepare(candidate, "3.29.14") + # Credential jobs check out the original baseline, without metadata + # changes. + release.git("reset", "--hard", baseline) + builds = directory / "builds" + builds.mkdir() + sample = directory / "sample" + wheel, source = archives(sample) + for index, name in enumerate(sorted(release.BUILD_ARTIFACTS)): + target = builds / name + target.mkdir() + if name == "source-dist": + shutil.copyfile(source, target / source.name) + else: + # Unique wheel tags stand in for the builder's independent + # platform artifacts. + destination = target / wheel.name.replace( + "x86_64.whl", f"platform{index}.whl" + ) + shutil.copyfile(wheel, destination) + yield directory, candidate, builds + + def test_tested_tree_signed_and_retained_bytes_reused(self): + with self.prepared() as (directory, candidate, builds): + state_dir = directory / "signed-state" + release.create_signed(candidate, builds, state_dir) + original = { + p.relative_to(state_dir): p.read_bytes() + for p in state_dir.rglob("*") + if p.is_file() + } + state = release.verify_state(state_dir) + self.assertEqual( + state["tree"], + release.git("rev-parse", state["candidate"] + "^{tree}"), + ) + self.assertNotEqual(state["commit"], state["candidate"]) + # Verification and recovery work after the signing credentials are + # removed. + del os.environ["RELEASE_GPG_PRIVATE_KEY_FILE"] + del os.environ["RELEASE_GPG_PASSPHRASE"] + release.verify_state(state_dir) + self.assertEqual( + original, + { + p.relative_to(state_dir): p.read_bytes() + for p in state_dir.rglob("*") + if p.is_file() + }, + ) + with self.assertRaisesRegex(release.Invalid, "overwrite"): + release.create_signed(candidate, builds, state_dir) + signature = state_dir / "SHA256SUMS.asc" + original_signature = signature.read_bytes() + signature.unlink() + with self.assertRaisesRegex(release.Invalid, "incomplete"): + release.verify_state(state_dir) + signature.write_bytes(original_signature) + package = next((state_dir / "dist").iterdir()) + package.write_bytes(b"altered") + with self.assertRaises( + (release.Invalid, zipfile.BadZipFile, tarfile.ReadError) + ): + release.verify_state(state_dir) + + def test_resume_preparation_uses_original_candidate_even_after_pypi_upload( + self, + ): + with self.prepared() as (directory, candidate, builds): + original = (candidate / "candidate.json").read_bytes() + with ( + patch.object(release, "blockers"), + patch.object(release, "restore", return_value=True), + patch.object(release, "pypi") as pypi, + ): + release.prepare(candidate, "3.29.14") + pypi.assert_not_called() + self.assertEqual( + original, (candidate / "candidate.json").read_bytes() + ) + + def test_candidate_tag_and_tree_consistency(self): + with self.prepared() as (directory, candidate, builds): + info = release.read_json(candidate / "candidate.json") + for key, value in [ + ("tag", "3.29.15-scylla"), + ("tree", "0" * 40), + ("baseline", "0" * 40), + ]: + changed = dict(info, **{key: value}) + release.write_json(candidate / "candidate.json", changed) + with self.assertRaises(release.Invalid): + release.load_candidate(candidate) + release.write_json(candidate / "candidate.json", info) + + def test_initial_launch_rejects_existing_tag_or_pypi_version(self): + with self.prepared() as (directory, candidate, builds): + baseline = os.environ["GITHUB_SHA"] + for tag, published in [("existing-tag", None), (None, [])]: + with ( + patch.object(release, "blockers"), + patch.object(release, "restore", return_value=False), + patch.object(release, "pypi", return_value=published), + patch.object( + release, + "remote_ref", + side_effect=lambda ref: baseline + if ref.endswith("/master") + else tag, + ), + ): + with self.assertRaises(release.Invalid): + release.prepare( + directory / "other-candidate", "3.29.14" + ) + + def test_nonmaster_launch_rejected_before_network(self): + with ( + patch.dict(os.environ, GITHUB_REF="refs/heads/branch-3.29"), + patch.object(release, "blockers") as blockers, + ): + with self.assertRaises(release.Invalid): + release.prepare(Path("/unused"), "3.29.14") + blockers.assert_not_called() + + def test_signing_failure_removes_temporary_keyrings(self): + with ( + self.prepared() as (directory, candidate, builds), + patch.object( + signing, + "sign_files", + side_effect=signing.SigningError("failed"), + ), + ): + with self.assertRaises(signing.SigningError): + release.create_signed(candidate, builds, directory / "state") + self.assertFalse(list(directory.glob("release-signing-*"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/test_signing.py b/scripts/release/test_signing.py new file mode 100644 index 0000000000..9d91f5a334 --- /dev/null +++ b/scripts/release/test_signing.py @@ -0,0 +1,1103 @@ +"Exercise release signing with disposable protected keys and real Git objects." + +from contextlib import contextmanager, redirect_stderr +from io import StringIO +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import Mock, patch +import signing + +ROOT = Path(__file__).resolve().parents[2] +REPOSITORY_PRIMARY = signing.PRIMARY +REPOSITORY_SIGNING = signing.SIGNING + + +def reap_signing_process(process, directory, agent=None): + try: + for home in directory.glob("release-signing-*/keyring"): + subprocess.run( + ["gpgconf", "--homedir", str(home), "--kill", "gpg-agent"], + capture_output=True, + timeout=10, + ) + if agent is not None: + try: + os.kill(agent, signal.SIGKILL) + except ProcessLookupError: + # Agent shutdown may finish between observing it and killing + # it. + pass + finally: + process.communicate(timeout=5) + + +@unittest.skipUnless( + shutil.which("gpg") and shutil.which("git"), "GnuPG and Git required" +) +class SigningFixture(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.fixture = tempfile.TemporaryDirectory( + prefix="repo-signing-fixture-" + ) + cls.addClassCleanup(cls.fixture.cleanup) + cls.directory = Path(cls.fixture.name) + cls.keyring = cls.directory / "gnupg" + cls.keyring.mkdir(mode=0o700) + cls.addClassCleanup( + subprocess.run, + ["gpgconf", "--homedir", str(cls.keyring), "--kill", "gpg-agent"], + capture_output=True, + check=True, + timeout=60, + ) + cls.password = "test-only-release-passphrase" + + def gpg(*args): + result = subprocess.run( + [ + "gpg", + "--no-options", + "--homedir", + str(cls.keyring), + "--batch", + "--pinentry-mode", + "loopback", + "--passphrase-fd", + "0", + *args, + ], + input=cls.password + "\n", + capture_output=True, + text=True, + check=True, + timeout=60, + ) + return result.stdout + + gpg( + "--quick-generate-key", + "Release Test ", + "rsa2048", + "cert", + "1d", + ) + cls.primary = [ + line.split(":")[9] + for line in gpg("--with-colons", "--list-keys").splitlines() + if line.startswith("fpr:") + ][0] + gpg("--quick-add-key", cls.primary, "rsa2048", "sign", "1d") + cls.subkey = [ + line.split(":")[9] + for line in gpg("--with-colons", "--list-keys").splitlines() + if line.startswith("fpr:") + ][1] + cls.public = cls.directory / "public.asc" + cls.public.write_text(gpg("--armor", "--export", cls.primary)) + cls.private = cls.directory / "subkey.asc" + cls.private.write_text( + gpg("--armor", "--export-secret-subkeys", cls.primary) + ) + cls.full = cls.directory / "full.asc" + cls.full.write_text( + gpg("--armor", "--export-secret-keys", cls.primary) + ) + + @contextmanager + def repository(self, full=False, password=None): + original = Path.cwd() + with tempfile.TemporaryDirectory(prefix="repo-signing-test-") as temp: + directory = Path(temp) + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("GIT_", "GITHUB_", "RELEASE_", "MAKE")) + and key not in ("MAVEN_GPG_PASSPHRASE", "PGP_PASSPHRASE") + } + env.update( + RUNNER_TEMP=str(directory), + GITHUB_ENV=str(directory / "github-env"), + GIT_CONFIG_NOSYSTEM="1", + GIT_CONFIG_GLOBAL=str(directory / "gitconfig"), + RELEASE_GPG_PRIVATE_KEY_FILE=str( + self.full if full else self.private + ), + RELEASE_GPG_PASSPHRASE=self.password + if password is None + else password, + ) + Path(env["GIT_CONFIG_GLOBAL"]).write_text("") + with ( + patch.dict(os.environ, env, clear=True), + patch.multiple( + signing, + PRIMARY=self.primary, + SIGNING=self.subkey, + PUBLIC_KEY=self.public, + ), + ): + repo = directory / "source" + signing.run(["git", "init", "--quiet", str(repo)]) + os.chdir(repo) + try: + signing.run( + [ + "git", + "-c", + "user.name=Initial", + "-c", + "user.email=initial@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "--allow-empty", + "-m", + "Initial", + ] + ) + yield directory + finally: + os.chdir(original) + + @contextmanager + def session(self): + with ( + signing.signing_session() as env, + patch.dict(os.environ, env, clear=True), + ): + self.assertNotIn("RELEASE_GPG_PRIVATE_KEY", env) + self.assertNotIn("RELEASE_GPG_PRIVATE_KEY_FILE", env) + yield env + + def release(self): + signing.run( + ["git", "commit", "--quiet", "--allow-empty", "-m", "Release"] + ) + signing.run(["git", "tag", "-s", "-m", "Release 1.0", "v1.0.0"]) + + @contextmanager + def make_repository(self): + with self.repository() as directory: + helper = Path("scripts/release/signing.py") + helper.parent.mkdir(parents=True) + helper.write_text( + (ROOT / "scripts/release/signing.py") + .read_text() + .replace(REPOSITORY_PRIMARY, self.primary) + .replace(REPOSITORY_SIGNING, self.subkey) + ) + shutil.copyfile(self.public, helper.with_name("publisher.asc")) + yield directory + + +class SigningTests(SigningFixture): + def test_repository_setup_failure_restores_working_directory(self): + original = Path.cwd() + real_run = signing.run + + def fail_commit(command, env=None): + if "commit" in command: + raise signing.SigningError("fixture commit failed") + return real_run(command, env) + + with patch.object(signing, "run", side_effect=fail_commit): + with self.assertRaisesRegex( + signing.SigningError, "fixture commit failed" + ): + with self.repository(): + self.fail("Initial commit failure accepted") + self.assertEqual(Path.cwd(), original) + with self.repository(): + self.assertTrue(Path(".git").is_dir()) + + def test_signed_commit_and_tag_verify_after_session(self): + with self.repository() as directory: + signing.run(["git", "config", "user.signingkey", "invalid-key"]) + config = Path(".git/config").read_bytes() + with self.session(): + self.release() + signing.verify_commit("HEAD") + signing.verify_tag( + "v1.0.0", signing.run(["git", "rev-parse", "HEAD"]).strip() + ) + self.assertEqual(Path(".git/config").read_bytes(), config) + self.assertFalse(list(directory.glob("release-signing-*"))) + self.assertFalse(Path(os.environ["GITHUB_ENV"]).exists()) + + def test_empty_passphrase_fails_before_configuration(self): + with self.repository(password="") as directory: + with ( + self.assertRaisesRegex(signing.SigningError, "nonempty"), + self.session(), + ): + self.fail("Empty passphrase accepted") + self.assertFalse(list(directory.glob("release-signing-*"))) + + def test_wrong_passphrase_fails_with_fresh_agent(self): + with self.repository(password="wrong-test-passphrase") as directory: + with self.assertRaises(signing.SigningError), self.session(): + self.fail("Wrong passphrase accepted") + self.assertFalse(list(directory.glob("release-signing-*"))) + + def test_primary_secret_rejected(self): + with self.repository(full=True): + with ( + self.assertRaisesRegex( + signing.SigningError, "signing-private.asc" + ), + self.session(), + ): + self.fail("Primary private key accepted") + + def test_unprotected_export_rejected_after_native_import(self): + with self.repository() as directory: + home = directory / "unprotected" + home.mkdir(mode=448) + env = {**os.environ, "GNUPGHOME": str(home)} + + def unprotected_gpg(*args): + return signing.gpg( + "--pinentry-mode", + "loopback", + "--passphrase", + "", + *args, + env=env, + ) + + try: + unprotected_gpg( + "--quick-generate-key", + "Unprotected Test ", + "rsa2048", + "cert", + "1d", + ) + primary = [ + row.split(":")[9] + for row in unprotected_gpg( + "--with-colons", "--list-keys" + ).splitlines() + if row.startswith("fpr:") + ][0] + unprotected_gpg( + "--quick-add-key", primary, "rsa2048", "sign", "1d" + ) + subkey = [ + row.split(":")[9] + for row in unprotected_gpg( + "--with-colons", "--list-keys" + ).splitlines() + if row.startswith("fpr:") + ][1] + private = unprotected_gpg( + "--armor", "--export-secret-subkeys", primary + ) + public = directory / "unprotected-public.asc" + public.write_text( + unprotected_gpg("--armor", "--export", primary) + ) + finally: + signing.run( + ["gpgconf", "--homedir", str(home), "--kill", "gpg-agent"] + ) + del os.environ["RELEASE_GPG_PRIVATE_KEY_FILE"] + os.environ["RELEASE_GPG_PRIVATE_KEY"] = private + os.environ["RELEASE_GPG_PASSPHRASE"] = "unrelated-test-passphrase" + with ( + patch.multiple( + signing, PRIMARY=primary, SIGNING=subkey, PUBLIC_KEY=public + ), + self.assertRaisesRegex( + signing.SigningError, "protected subkey" + ), + self.session(), + ): + self.fail("Unprotected subkey export accepted") + self.assertFalse(list(directory.glob("release-signing-*"))) + + def test_unsigned_commit_and_tag_rejected(self): + with self.repository(): + with self.session(): + signing.run( + [ + "git", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "--allow-empty", + "-m", + "Unsigned", + ] + ) + signing.run( + [ + "git", + "-c", + "tag.gpgSign=false", + "tag", + "-a", + "-m", + "Unsigned", + "v1.0.0", + ] + ) + with self.assertRaises(signing.SigningError): + signing.verify_commit("HEAD") + with self.assertRaises(signing.SigningError): + signing.verify_tag("v1.0.0") + + def test_tag_at_unexpected_commit_rejected(self): + with self.repository(): + with self.session(): + self.release() + signing.run( + [ + "git", + "commit", + "--quiet", + "--allow-empty", + "-m", + "Later", + ] + ) + with self.assertRaisesRegex( + signing.SigningError, "unexpected commit" + ): + signing.verify_tag( + "v1.0.0", signing.run(["git", "rev-parse", "HEAD"]).strip() + ) + + def test_batch_signing_reuse_and_tamper_rejection(self): + with self.repository(), self.session(): + files = [ + Path("package.jar"), + Path("package.pom"), + Path("SHA256SUMS"), + ] + for artifact in files: + artifact.write_bytes(b"release package") + signing.sign_files(files) + signatures = [ + Path(str(artifact) + ".asc").read_bytes() for artifact in files + ] + signing.sign_files(files) + self.assertEqual( + [ + Path(str(artifact) + ".asc").read_bytes() + for artifact in files + ], + signatures, + ) + files[0].write_bytes(b"changed package") + with self.assertRaises(signing.SigningError): + signing.sign_files(files) + + def test_direct_sign_files_preserves_public_verification( + self, + ): + with self.make_repository() as directory: + default_home = directory / "empty-default-keyring" + default_home.mkdir(mode=448) + os.environ["GNUPGHOME"] = str(default_home) + files = [Path("package.jar"), Path("package.pom")] + for artifact in files: + artifact.write_bytes(b"release package") + command = [ + sys.executable, + "scripts/release/signing.py", + "sign-files", + ] + + def invoke(*artifacts): + result = subprocess.run( + command + list(map(str, artifacts)), + capture_output=True, + text=True, + timeout=60, + ) + self.assertNotIn(self.password, result.stdout + result.stderr) + self.assertNotIn("PRIVATE KEY", result.stdout + result.stderr) + return result + + result = invoke(files[0]) + self.assertEqual(result.returncode, 0, result.stderr) + first_signature = Path(str(files[0]) + ".asc").read_bytes() + del os.environ["RELEASE_GPG_PRIVATE_KEY_FILE"] + os.environ["RELEASE_GPG_PRIVATE_KEY"] = self.private.read_text() + result = invoke(*files) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + Path(str(files[0]) + ".asc").read_bytes(), first_signature + ) + signatures = [ + Path(str(artifact) + ".asc").read_bytes() for artifact in files + ] + del os.environ["RELEASE_GPG_PRIVATE_KEY"] + del os.environ["RELEASE_GPG_PASSPHRASE"] + result = invoke(*files) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + [ + Path(str(artifact) + ".asc").read_bytes() + for artifact in files + ], + signatures, + ) + files[0].write_bytes(b"tampered release") + self.assertNotEqual(invoke(*files).returncode, 0) + self.assertFalse(list(default_home.iterdir())) + self.assertFalse(list(directory.glob("release-signing-*"))) + + @unittest.skipUnless( + Path("/proc/self/stat").exists(), "Linux process observation required" + ) + def test_exec_stops_group_before_keyring_cleanup( + self, + ): + self.check_exec_termination("active") + + @unittest.skipUnless( + Path("/proc/self/stat").exists(), "Linux process observation required" + ) + def test_exec_startup_interruption_keeps_child_owned_until_cleanup(self): + self.check_exec_termination("startup") + + @unittest.skipUnless( + Path("/proc/self/stat").exists(), "Linux process observation required" + ) + def test_normal_teardown_interruption_finishes_agent_and_keyring_cleanup( + self, + ): + self.check_exec_termination("teardown") + + def check_exec_termination(self, window): + def running(pid): + try: + return ( + Path(f"/proc/{pid}/stat") + .read_text() + .rsplit(")", 1)[1] + .split()[0] + != "Z" + ) + except (FileNotFoundError, ProcessLookupError): + return False + + for signum in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + with ( + self.subTest(signum=signum), + self.make_repository() as directory, + ): + ready, stopped = (directory / "ready", directory / "stopped") + started = directory / "started" + agent_shutdown = directory / "agent-shutdown" + descendant_ready = directory / "descendant-ready" + descendant = ( + f"import os,signal,time\n" + f"from pathlib import Path\n" + f"for s in (signal.SIGTERM,signal.SIGHUP," + f"signal.SIGINT): signal.signal(s,signal.SIG_IGN)\n" + f"Path({str(descendant_ready)!r})" + f".write_text(str(os.getpid(" + f")))\n" + f"time.sleep(60)\n" + ) + script = ( + f"import json,os,signal,subprocess,sys,time\n" + f"from pathlib import Path\n" + f"os.close(1)\n" + f"os.close(2)\n" + f"Path({str(started)!r}).write_text(" + f"str(os.getpid()))\n" + f"child=subprocess.Popen([sys.executable," + f"'-c',{descendant!r}])\n" + f"home=Path(os.environ['GNUPGHOME'])\n" + f"agent=subprocess.run(['gpg-connect-agent'," + f"'--no-autostart','--homedir',str(home)," + f"'GETINFO pid','/bye'],capture_output=True," + f"text=True,check=True).stdout\n" + f"agent=int(next(line[2:] for " + f"line in agent.splitlines(" + f") " + f"if line.startswith('D ')))\n" + f"def interrupted(s,frame):\n" + f" Path({str(stopped)!r}).write_text(" + f"json.dumps({{'home_exists':" + f"home.exists()," + f"'private_exists':(home/'private-keys" + f"-v1.d').is_dir()}}))\n" + f" raise SystemExit(128+s)\n" + f"for s in (signal.SIGTERM,signal.SIGHUP," + f"signal.SIGINT): signal.signal(s,interrupted)\n" + f"deadline=time.monotonic()+3\n" + f"while not Path({str(descendant_ready)!r}).exists() " + f"and time.monotonic()-") + .strip() + .removeprefix("${{") + .removesuffix("}}") + .strip() + ) + condition = condition.replace("cancelled()", repr(cancelled)) + condition = re.sub( + r"needs\.[a-z-]+\.(?:result|outputs\.[a-z-]+)|inputs\.[a-z-]+", + lambda match: repr(values[match.group()]), + condition, + ) + condition = condition.replace("&&", " and ").replace("||", " or ") + condition = re.sub(r"!(?!=)", " not ", condition) + return eval(" ".join(condition.split()), {"__builtins__": {}}, {}) + + +def gate(job, values, cancelled=False): + condition = re.search( + r"\n if: (.+?)(?=\n [a-z-]+:)", job_source(job), re.S + ).group(1) + return expression(condition, values, cancelled) + + +class WorkflowTests(unittest.TestCase): + def test_signing_requires_all_validation_or_original_retained_state(self): + for job, dry_run in (("sign", True), ("publish", False)): + baseline = { + "needs.prepare.result": "success", + "needs.prepare.outputs.retained": "false", + "needs.build.result": "success", + "needs.metadata.result": "success", + "needs.integration.result": "success", + "inputs.skip-integration-matrix": False, + "inputs.dry-run": dry_run, + } + with self.subTest(job=job): + self.assertTrue(gate(job, baseline)) + self.assertFalse( + gate( + job, dict(baseline, **{"inputs.dry-run": not dry_run}) + ) + ) + for dep in ( + "prepare", + "build", + "metadata", + "integration", + ): + for result in ("failure", "cancelled"): + with self.subTest( + dep=dep, result=result + ): + self.assertFalse( + gate( + job, + dict( + baseline, + **{ + f"needs.{dep}.result": result, + }, + ), + ) + ) + skipped = dict( + baseline, + **{ + "needs.integration.result": "skipped", + }, + ) + self.assertFalse(gate(job, skipped)) + self.assertTrue( + gate( + job, + dict( + skipped, + **{ + "inputs.skip-integration-matrix": True, + }, + ), + ) + ) + retained = dict( + baseline, + **{ + "needs.prepare.outputs.retained": "true", + "needs.build.result": "skipped", + "needs.metadata.result": "skipped", + "needs.integration.result": "skipped", + }, + ) + self.assertTrue(gate(job, retained)) + self.assertFalse(gate(job, retained, cancelled=True)) + self.assertFalse( + gate( + job, + dict( + retained, + **{ + "needs.prepare.result": "failure", + }, + ), + ) + ) + + def test_only_real_releases_use_the_approval_environment(self): + source = WORKFLOW.read_text() + self.assertEqual(source.count(" environment: release\n"), 1) + self.assertIn(" environment: release-dry-run\n", job_source("sign")) + self.assertIn(" environment: release\n", job_source("publish")) + self.assertIn( + "needs: [prepare, build, metadata, integration]", + job_source("publish"), + ) + for job in ("prepare", "build", "metadata", "integration", "docs"): + with self.subTest(job=job): + self.assertNotIn("environment:", job_source(job)) + self.assertNotIn("secrets.GPG_", job_source(job)) + self.assertNotIn("secrets.PUBLISHER_TOKEN", job_source(job)) + self.assertNotIn("id-token: write", job_source(job)) + self.assertNotIn("secrets: inherit", job_source(job)) + self.assertNotIn("secrets.PUBLISHER_TOKEN", job_source("sign")) + self.assertNotIn("id-token: write", job_source("sign")) + for secret in ("GPG_PRIVATE_KEY", "GPG_PASSPHRASE"): + self.assertIn("secrets." + secret, job_source("sign")) + self.assertIn("secrets." + secret, job_source("publish")) + self.assertIn("secrets.PUBLISHER_TOKEN", job_source("publish")) + self.assertIn("id-token: write", job_source("publish")) + + def test_checkpoint_precedes_publication_in_the_only_approved_job(self): + for job in ("sign", "publish"): + with self.subTest(job=job): + source = job_source(job) + restore = source.index('release.py" restore') + sign = source.index('release.py" sign') + verify = source.index('release.py" verify') + checkpoint = source.index("name: release-state") + self.assertLess(restore, sign) + self.assertLess(sign, verify) + self.assertLess(verify, checkpoint) + self.assertIn("GH_TOKEN: ${{ github.token }}", source) + self.assertIn("args+=(--require-retained)", source) + self.assertNotIn("needs.prepare.outputs.retained !=", source) + self.assertEqual( + source.count( + "if: steps.restore.outputs.retained != 'true'" + ), + 4, + ) + if job == "publish": + preflight = source.index('release.py" prepublish') + upload = source.index("pypa/gh-action-pypi-publish@") + finish = source.index('release.py" finish') + self.assertLess(checkpoint, preflight) + self.assertLess(preflight, upload) + self.assertLess(upload, finish) + else: + self.assertNotIn("pypa/gh-action-pypi-publish", source) + self.assertNotIn("prepublish", source) + self.assertNotIn('release.py" finish', source) + + def test_dry_runs_preserve_project_and_integration_validation(self): + for dry_run in (False, True): + values = { + "inputs.dry-run": dry_run, + "inputs.skip-integration-matrix": False, + "needs.prepare.outputs.retained": "false", + } + self.assertTrue(gate("build", values)) + self.assertTrue(gate("integration", values)) + self.assertFalse( + gate( + "integration", + dict(values, **{"inputs.skip-integration-matrix": True}), + ) + ) + self.assertIn( + "ignore_tests: ${{ inputs.skip-tests }}", job_source("build") + ) + matrix = WORKFLOW.with_name("release-matrix.yml").read_text() + self.assertIn("scylla: [LATEST, PRIOR, LTS-LATEST, LTS-PRIOR]", matrix) + self.assertIn("PROTOCOLS: '3,4'", matrix) + for override in ("PYTEST_ADDOPTS", "--ignore=", "--deselect="): + self.assertNotIn(override, matrix) + + def test_dry_runs_and_failures_cannot_publish_or_deploy_docs(self): + for dry_run in (True, False): + for result in ("success", "failure", "skipped", "cancelled"): + for job, dependency in (("docs", "publish"),): + values = { + f"needs.{dependency}.result": result, + "inputs.dry-run": dry_run, + } + with self.subTest(job=job, result=result, dry_run=dry_run): + self.assertEqual( + gate(job, values), + result == "success" and not dry_run, + ) + self.assertFalse(gate(job, values, cancelled=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_release_helpers.py b/tests/unit/test_release_helpers.py new file mode 100644 index 0000000000..31305bf430 --- /dev/null +++ b/tests/unit/test_release_helpers.py @@ -0,0 +1,185 @@ +# Copyright ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"Exercise release metadata and publication guards in the primary unit suite." + +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + + +def load_helpers(): + directory = Path(__file__).resolve().parents[2] / "scripts/release" + + def load(name, filename): + spec = importlib.util.spec_from_file_location( + name, directory / filename + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + signing = load("_driver_release_signing", "signing.py") + # The CLI imports its adjacent signing module by name. Keep that alias + # local + # to loading the helper so collection cannot affect other unit tests. + missing = object() + previous = sys.modules.get("signing", missing) + sys.modules["signing"] = signing + try: + return load("_driver_release", "release.py") + finally: + if previous is missing: + del sys.modules["signing"] + else: + sys.modules["signing"] = previous + + +release = load_helpers() + + +class ReleaseVersionTests(unittest.TestCase): + def test_canonical_stable_version(self): + self.assertEqual(release.stable("3.29.14"), (3, 29, 14)) + self.assertGreater(release.stable("3.30.0"), release.stable("3.29.99")) + + def test_ambiguous_or_prerelease_versions_are_rejected(self): + for version in ( + "3.29", + "3.29.14rc1", + "03.29.14", + "3.29.14-scylla", + "3.29.14\n", + ): + with ( + self.subTest(version=version), + self.assertRaises(release.Invalid), + ): + release.stable(version) + + def test_preparation_promotes_changelog_and_updates_all_versions(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + files = { + "cassandra/__init__.py": "__version_info__ = (3, 29, 13)\n", + "CHANGELOG.rst": "Unreleased\n==========\n\nBug " + "Fixes\n---------\n* Fixed " + "routing.\n\n3.29.13\n=======\n2026-11-02\n", + "docs/conf.py": "TAGS = [\n '3.29.13-scylla',\n]\n" + "LATEST_VERS" + "ION = '3.29.13-scylla'\n", + "docs/installation.rst": "It should print something like " + '"3.29.13".\n', + } + for name, content in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + release.prepare_metadata(root, "3.29.14", "2026-11-05") + self.assertEqual( + (root / "cassandra/__init__.py").read_text(), + "__version_info__ = (3, 29, 14)\n", + ) + changelog = (root / "CHANGELOG.rst").read_text() + self.assertTrue( + changelog.startswith( + "Unreleased\n==========\n\n3.29.14\n=======\n2026-11-05\n" + ) + ) + self.assertEqual(changelog.count("* Fixed routing."), 1) + self.assertIn("3.29.13\n=======\n2026-11-02\n", changelog) + conf = (root / "docs/conf.py").read_text() + self.assertIn(" '3.29.14-scylla',\n]", conf) + self.assertIn("LATEST_VERSION = '3.29.14-scylla'", conf) + self.assertIn( + '"3.29.14"', (root / "docs/installation.rst").read_text() + ) + with self.assertRaisesRegex(release.Invalid, "increase"): + release.prepare_metadata(root, "3.29.14", "2026-11-05") + + +class ReleasePublicationGuardTests(unittest.TestCase): + def test_failed_publication_rerun_selects_existing_signed_checkpoint(self): + with ( + patch.object(release, "restore", return_value=True) as restore, + patch.object(release, "output") as output, + ): + release.restore_state(Path("/state")) + restore.assert_called_once_with("release-state", Path("/state")) + output.assert_called_once_with(retained="true") + + def test_missing_expected_checkpoint_cannot_enable_signing(self): + with ( + patch.object(release, "restore", return_value=False), + patch.object(release, "output") as output, + ): + with self.assertRaisesRegex(release.Invalid, "manual recovery"): + release.restore_state(Path("/state"), require_retained=True) + output.assert_not_called() + + def test_only_missing_pypi_files_are_selected(self): + expected = {"linux.whl": "linux-hash", "windows.whl": "windows-hash"} + uploaded = [ + {"filename": "linux.whl", "digests": {"sha256": "linux-hash"}} + ] + self.assertEqual( + release.missing_packages(expected, uploaded), ["windows.whl"] + ) + + def test_conflicting_or_yanked_pypi_files_are_rejected(self): + for item in ( + {"filename": "linux.whl", "digests": {"sha256": "changed"}}, + {"filename": "extra.whl", "digests": {"sha256": "original"}}, + { + "filename": "linux.whl", + "digests": {"sha256": "original"}, + "yanked": True, + }, + ): + with self.subTest(item=item), self.assertRaises(release.Invalid): + release.missing_packages({"linux.whl": "original"}, [item]) + + def test_branch_movement_requires_manual_recovery(self): + with patch.object( + release, "remote_ref", return_value="intervening-commit" + ): + with self.assertRaisesRegex(release.Invalid, "manual recovery"): + release.check_branch({"baseline": "baseline"}, "signed-commit") + + def test_retained_signed_commit_is_an_allowed_recovery_baseline(self): + with patch.object(release, "remote_ref", return_value="signed-commit"): + release.check_branch({"baseline": "baseline"}, "signed-commit") + + def test_release_blockers_are_checked_past_the_first_page(self): + first = [{"pull_request": {}, "html_url": "pull-request"}] * 100 + second = [ + { + "html_url": "https://github.com/scylladb/python-driver/is" + "sues/123" + } + ] + with patch.object(release, "api", side_effect=[first, second]) as api: + with self.assertRaisesRegex(release.Invalid, "/123"): + release.blockers() + self.assertIn("page=2", api.call_args.args[0]) + + def test_blocker_api_errors_cannot_allow_publication(self): + with patch.object( + release, "api", side_effect=release.Invalid("request failed") + ): + with self.assertRaises(release.Invalid): + release.blockers()