From 6fe8004915bc641a3877b608e734d989fb60a2e3 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 5 Nov 2026 13:57:22 -0400 Subject: [PATCH 1/3] release: add signed preparation and recovery helpers Pin stable release metadata and retain the tested source tree before publication. Preserve signed history and distribution bytes so retries can verify existing uploads and continue without moving tags or signing published files again. Add public-only signature verification, isolated signing-key cleanup and helper coverage for failure, cancellation and partial-upload recovery. Run portable metadata guards in the primary unit suite as well. --- MANIFEST.in | 3 +- scripts/release/.gitignore | 1 + scripts/release/README.md | 186 +++++ scripts/release/publisher.asc | 64 ++ scripts/release/release.py | 1012 ++++++++++++++++++++++++ scripts/release/signing.py | 623 +++++++++++++++ scripts/release/test_publication.py | 317 ++++++++ scripts/release/test_release.py | 469 ++++++++++++ scripts/release/test_signing.py | 1103 +++++++++++++++++++++++++++ tests/unit/test_release_helpers.py | 167 ++++ 10 files changed, 3944 insertions(+), 1 deletion(-) create mode 100644 scripts/release/.gitignore create mode 100644 scripts/release/README.md create mode 100644 scripts/release/publisher.asc create mode 100644 scripts/release/release.py create mode 100644 scripts/release/signing.py create mode 100644 scripts/release/test_publication.py create mode 100644 scripts/release/test_release.py create mode 100644 scripts/release/test_signing.py create mode 100644 tests/unit/test_release_helpers.py 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..52384df443 --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,186 @@ +# 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, the `release` environment requires approval before 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. Real signing and publication keep +using the approval-protected `release` environment. + +`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. A rerun of just the failed publication job downloads the same state. +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..510fb42a46 --- /dev/null +++ b/scripts/release/release.py @@ -0,0 +1,1012 @@ +#!/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 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", + "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")) + args = parser.parse_args() + try: + if args.operation == "prepare": + prepare(args.candidate, args.version) + 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..5c7df80c6d --- /dev/null +++ b/scripts/release/test_release.py @@ -0,0 +1,469 @@ +""" +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_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() Date: Thu, 5 Nov 2026 13:57:22 -0400 Subject: [PATCH 2/3] ci: orchestrate manual Python driver releases Replace tag-triggered uploads with a manual master-only release flow. Validate the bundled candidate across the existing wheel targets and the pinned integration matrix before signing and publication. Run complete dry runs with signing secrets and no approval gate. Keep real releases behind approval, publish through PyPI OIDC, and deploy docs after the retained history and immutable GitHub release are verified. --- .github/workflows/build-pre-release.yml | 2 +- .github/workflows/build-push.yml | 26 +-- .github/workflows/docs-pages.yml | 9 +- .github/workflows/lib-build.yml | 62 +++++++ .github/workflows/release-matrix.yml | 214 +++++++++++++++++++++++ .github/workflows/release.yml | 220 ++++++++++++++++++++++++ scripts/release/test_workflow.py | 146 ++++++++++++++++ 7 files changed, 653 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/release-matrix.yml create mode 100644 .github/workflows/release.yml create mode 100644 scripts/release/test_workflow.py 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..2b6a71645c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,220 @@ +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: >- + ${{ !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 use the same signing certificate without a reviewer gate. This + # master-only environment contains signing secrets, never the publisher token. + environment: ${{ inputs.dry-run && 'release-dry-run' || 'release' }} + 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 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: needs.prepare.outputs.retained == 'true' + with: + name: release-state + path: ${{ runner.temp }}/signed-state + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: needs.prepare.outputs.retained != 'true' + with: + pattern: wheels-* + path: ${{ runner.temp }}/builds + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: needs.prepare.outputs.retained != 'true' + with: + name: source-dist + path: ${{ runner.temp }}/builds/source-dist + - name: Sign tested tree, tag, packages and checksum manifest + if: needs.prepare.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: needs.prepare.outputs.retained != 'true' + with: + name: release-state + path: ${{ runner.temp }}/signed-state/ + if-no-files-found: error + retention-days: 90 + + publish: + needs: sign + if: ${{ !cancelled() && needs.sign.result == 'success' && !inputs.dry-run }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: release + permissions: + contents: 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-state + path: ${{ runner.temp }}/signed-state + - 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/scripts/release/test_workflow.py b/scripts/release/test_workflow.py new file mode 100644 index 0000000000..5aa2690ee0 --- /dev/null +++ b/scripts/release/test_workflow.py @@ -0,0 +1,146 @@ +"""Evaluate the release job gates for skips, recovery, failure and dry runs.""" + +from pathlib import Path +import re +import unittest + +WORKFLOW = ( + Path(__file__).resolve().parents[2] / ".github/workflows/release.yml" +) + + +def job_source(job): + source = WORKFLOW.read_text().split(f"\n {job}:\n", 1)[1] + return re.split(r"\n [a-z][a-z-]*:\n", source, maxsplit=1)[0] + + +def expression(condition, values, cancelled=False): + # These job conditions use only literals, equality and boolean operators. + # actionlint separately checks the complete YAML and GitHub expression + # syntax. + condition = ( + condition.removeprefix(">-") + .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): + 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, + } + self.assertTrue(gate("sign", baseline)) + for job in ("prepare", "build", "metadata", "integration"): + for result in ("failure", "cancelled"): + with self.subTest(job=job, result=result): + self.assertFalse( + gate( + "sign", + dict(baseline, **{f"needs.{job}.result": result}), + ) + ) + skipped = dict(baseline, **{"needs.integration.result": "skipped"}) + self.assertFalse(gate("sign", skipped)) + self.assertTrue( + gate( + "sign", + 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("sign", retained)) + self.assertFalse(gate("sign", retained, cancelled=True)) + self.assertFalse( + gate("sign", dict(retained, **{"needs.prepare.result": "failure"})) + ) + + def test_only_real_releases_use_the_approval_environment(self): + environment = re.search( + r"^ environment: (.+)$", job_source("sign"), re.M + ).group(1) + self.assertEqual( + expression(environment, {"inputs.dry-run": True}), + "release-dry-run", + ) + self.assertEqual( + expression(environment, {"inputs.dry-run": False}), "release" + ) + self.assertIn(" environment: release\n", job_source("publish")) + + 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 ( + ("publish", "sign"), + ("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() From 25136656e0da3cbed31f824497a2824512855160 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 5 Nov 2026 15:37:06 -0400 Subject: [PATCH 3/3] release: gate signing and publication in one credential job Run preparation, builds and validation before the protected environment. Keep dry-run signing approval-free without publisher or OIDC credentials. On failed-job reruns, recheck the current run signed checkpoint before signing so saved history and signatures remain unchanged. --- .github/workflows/release.yml | 82 ++++++++++--- scripts/release/README.md | 15 ++- scripts/release/release.py | 14 +++ scripts/release/test_release.py | 59 +++++++++ scripts/release/test_workflow.py | 184 ++++++++++++++++++++--------- tests/unit/test_release_helpers.py | 18 +++ 6 files changed, 297 insertions(+), 75 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b6a71645c..3f3095221e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,16 +111,18 @@ jobs: sign: needs: [prepare, build, metadata, integration] if: >- - ${{ !cancelled() && needs.prepare.result == 'success' && + ${{ 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 use the same signing certificate without a reviewer gate. This - # master-only environment contains signing secrets, never the publisher token. - environment: ${{ inputs.dry-run && 'release-dry-run' || 'release' }} + # 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: @@ -135,23 +137,27 @@ jobs: 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: needs.prepare.outputs.retained == 'true' - with: - name: release-state - path: ${{ runner.temp }}/signed-state - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - if: needs.prepare.outputs.retained != 'true' + if: steps.restore.outputs.retained != 'true' with: pattern: wheels-* path: ${{ runner.temp }}/builds - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - if: needs.prepare.outputs.retained != 'true' + 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: needs.prepare.outputs.retained != 'true' + if: steps.restore.outputs.retained != 'true' env: RELEASE_GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} RELEASE_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} @@ -160,7 +166,7 @@ jobs: 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: needs.prepare.outputs.retained != 'true' + if: steps.restore.outputs.retained != 'true' with: name: release-state path: ${{ runner.temp }}/signed-state/ @@ -168,13 +174,20 @@ jobs: retention-days: 90 publish: - needs: sign - if: ${{ !cancelled() && needs.sign.result == 'success' && !inputs.dry-run }} + 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 + 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: @@ -190,9 +203,44 @@ jobs: 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 + 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: diff --git a/scripts/release/README.md b/scripts/release/README.md index 52384df443..c9116d6ac6 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -29,8 +29,10 @@ external matrix has its own `skip-integration-matrix` input. Open issues carryin `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, the `release` environment requires approval before helpers -from the launch SHA sign the +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` @@ -68,8 +70,8 @@ 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. Real signing and publication keep -using the approval-protected `release` environment. +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, @@ -124,7 +126,10 @@ 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. A rerun of just the failed publication job downloads the same state. +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, diff --git a/scripts/release/release.py b/scripts/release/release.py index 510fb42a46..3462be0a1f 100644 --- a/scripts/release/release.py +++ b/scripts/release/release.py @@ -199,6 +199,16 @@ def restore(name, 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 @@ -953,6 +963,7 @@ def main(): "operation", choices=[ "prepare", + "restore", "collect", "sign", "verify", @@ -981,10 +992,13 @@ def main(): 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": diff --git a/scripts/release/test_release.py b/scripts/release/test_release.py index 5c7df80c6d..f03b3cf528 100644 --- a/scripts/release/test_release.py +++ b/scripts/release/test_release.py @@ -233,6 +233,65 @@ def test_missing_artifact_and_api_error_distinguished(self): 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={}), diff --git a/scripts/release/test_workflow.py b/scripts/release/test_workflow.py index 5aa2690ee0..e7343f93fb 100644 --- a/scripts/release/test_workflow.py +++ b/scripts/release/test_workflow.py @@ -45,59 +45,140 @@ def gate(job, values, cancelled=False): class WorkflowTests(unittest.TestCase): def test_signing_requires_all_validation_or_original_retained_state(self): - 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, - } - self.assertTrue(gate("sign", baseline)) - for job in ("prepare", "build", "metadata", "integration"): - for result in ("failure", "cancelled"): - with self.subTest(job=job, result=result): - self.assertFalse( - gate( - "sign", - dict(baseline, **{f"needs.{job}.result": result}), - ) + 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}) ) - skipped = dict(baseline, **{"needs.integration.result": "skipped"}) - self.assertFalse(gate("sign", skipped)) - self.assertTrue( - gate( - "sign", - 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("sign", retained)) - self.assertFalse(gate("sign", retained, cancelled=True)) - self.assertFalse( - gate("sign", dict(retained, **{"needs.prepare.result": "failure"})) - ) + ) + 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): - environment = re.search( - r"^ environment: (.+)$", job_source("sign"), re.M - ).group(1) - self.assertEqual( - expression(environment, {"inputs.dry-run": True}), - "release-dry-run", - ) - self.assertEqual( - expression(environment, {"inputs.dry-run": False}), "release" - ) + 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): @@ -126,10 +207,7 @@ def test_dry_runs_preserve_project_and_integration_validation(self): 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 ( - ("publish", "sign"), - ("docs", "publish"), - ): + for job, dependency in (("docs", "publish"),): values = { f"needs.{dependency}.result": result, "inputs.dry-run": dry_run, diff --git a/tests/unit/test_release_helpers.py b/tests/unit/test_release_helpers.py index f42a2f54c6..31305bf430 100644 --- a/tests/unit/test_release_helpers.py +++ b/tests/unit/test_release_helpers.py @@ -113,6 +113,24 @@ def test_preparation_promotes_changelog_and_updates_all_versions(self): 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 = [