diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000..06c15ad --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -0,0 +1,100 @@ +name: Prepare Release +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump type" + required: true + type: choice + options: + - auto + - major + - minor + - patch + default: auto + +permissions: + contents: read + +env: + FORCE_COLOR: 1 + +jobs: + prepare-release: + runs-on: ubuntu-24.04 + environment: release-auth + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PAT }} + persist-credentials: true + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + + - name: Calculate next version + id: version + env: + BUMP: ${{ inputs.bump }} + run: | + current=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0") + echo "Current version: $current" + + IFS='.' read -r major minor patch <<< "$current" + + bump_type="$BUMP" + if [ "$bump_type" = "auto" ]; then + feature_count=$(find docs/changelog -name "*.feature.rst" 2>/dev/null | wc -l) + breaking_count=$(find docs/changelog -name "*.breaking.rst" 2>/dev/null | wc -l) + if [ "$breaking_count" -gt 0 ]; then + bump_type="major" + echo "Auto-detected: major bump (found $breaking_count breaking changelog(s))" + elif [ "$feature_count" -gt 0 ]; then + bump_type="minor" + echo "Auto-detected: minor bump (found $feature_count feature changelog(s))" + else + bump_type="patch" + echo "Auto-detected: patch bump (no feature changelogs)" + fi + fi + + case "$bump_type" in + major) + next="$((major + 1)).0.0" + ;; + minor) + next="$major.$((minor + 1)).0" + ;; + patch) + next="$major.$minor.$((patch + 1))" + ;; + esac + + echo "Next version: $next" + echo "version=$next" >> $GITHUB_OUTPUT + + - name: Install tox + run: uv tool install --python-preference only-managed --python 3.14 tox@latest --with tox-uv + + - name: Run release process + run: tox run -e release -- ${STEPS_VERSION_OUTPUTS_VERSION} + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + + - name: Display completion message + run: echo "Release ${STEPS_VERSION_OUTPUTS_VERSION} prepared and pushed successfully!" + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..b5e6ce8 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,113 @@ +################# + Release History +################# + +.. towncrier-draft-entries:: + +.. towncrier release notes start + +******************** + v1.3.0 (2026-05-05) +******************** + +Features - 1.3.0 +================ + +- Add :func:`~python_discovery.iter_interpreters` for enumerating every discovered interpreter, with PATH and + UV-install support for non-CPython implementations listed in :data:`~python_discovery.KNOWN_IMPLEMENTATIONS` + (:pull:`71`) + +******************** + v1.2.2 (2026-04-06) +******************** + +Features - 1.2.2 +================ + +- Export ``normalize_isa`` and deprecate ``KNOWN_ARCHITECTURES`` (:pull:`62`) + +******************** + v1.2.1 (2026-03-26) +******************** + +Features - 1.2.1 +================ + +- Expose ``KNOWN_ARCHITECTURES`` as public API (:pull:`56`) + +Contributor-facing changes - 1.2.1 +================================== + +- Add zizmor security auditing for workflows (:pull:`55`) + +******************** + v1.2.0 (2026-03-18) +******************** + +Features - 1.2.0 +================ + +- Increase interpreter query timeout to 15s, with an override (:pull:`53`) + +******************** + v1.1.3 (2026-03-10) +******************** + +Bug fixes - 1.1.3 +================= + +- Add ``loongarch64`` to known ISAs (:pull:`50`) + +******************** + v1.1.2 (2026-03-09) +******************** + +Bug fixes - 1.1.2 +================= + +- Match prerelease versions against ``major.minor`` specs (:pull:`48`) +- Drain pipes after killing a timed-out interpreter probe (:pull:`49`) + +Improved documentation - 1.1.2 +============================== + +- Add package description and usage to the README (:pull:`46`) + +******************** + v1.1.1 (2026-03-06) +******************** + +Bug fixes - 1.1.1 +================= + +- Add a timeout to interpreter probing (:pull:`42`) +- Add ``i686`` to known ISAs (:pull:`43`) + +Contributor-facing changes - 1.1.1 +================================== + +- Add a security policy and workflow permissions hardening (:pull:`33`) + +******************** + v1.1.0 (2026-02-26) +******************** + +Features - 1.1.0 +================ + +- Add a ``predicate`` parameter to :func:`~python_discovery.get_interpreter` (:pull:`31`) + +Improved documentation - 1.1.0 +============================== + +- Fix the ReadTheDocs build (:pull:`29`) +- Add ``:param:`` descriptions to all public APIs (:pull:`32`) + +******************** + v1.0.0 (2026-02-25) +******************** + +Features - 1.0.0 +================ + +- Initial release as a standalone package, extracted from ``virtualenv`` (:pull:`28`) diff --git a/docs/changelog/65.feature.rst b/docs/changelog/65.feature.rst deleted file mode 100644 index 412641f..0000000 --- a/docs/changelog/65.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -add :func:`~python_discovery.iter_interpreters` for enumerating every discovered interpreter, with PATH and -UV-install support for non-CPython implementations listed in :data:`~python_discovery.KNOWN_IMPLEMENTATIONS` -- by :user:`gaborbernat`. diff --git a/docs/changelog/template.jinja2 b/docs/changelog/template.jinja2 new file mode 100644 index 0000000..826adc2 --- /dev/null +++ b/docs/changelog/template.jinja2 @@ -0,0 +1,38 @@ +{% set top_underline = "*" %} +{% set underline = "=" %} +{% if versiondata.name %} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 5)}} + v{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 5)}} +{% else %} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} + {{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} +{% endif %} + +{% for section, _ in sections.items() %} +{% if sections[section] %} +{% for category, val in definitions.items() if category in sections[section]%} +{{ definitions[category]['name'] }} - {{ versiondata.version }} +{{ underline * ((definitions[category]['name'] + versiondata.version)|length + 3)}} +{% if definitions[category]['showcontent'] %} +{% for text, values in sections[section][category].items() %} +- {{ text }} ({{ values|join(', ') }}) +{% endfor %} + +{% else %} +- {{ sections[section][category]['']|join(', ') }} + +{% endif %} +{% if sections[section][category]|length == 0 %} +No significant changes. + +{% else %} +{% endif %} +{% endfor %} +{% else %} +No significant changes. + + +{% endif %} +{% endfor %} diff --git a/docs/conf.py b/docs/conf.py index fc0f056..5cd682a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone +from pathlib import Path from python_discovery import __version__ @@ -19,8 +20,13 @@ "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", "sphinxcontrib.mermaid", + "sphinxcontrib.towncrier.ext", ] +towncrier_draft_autoversion_mode = "draft" +towncrier_draft_include_empty = True +towncrier_draft_working_directory = Path(__file__).parent.parent + extlinks = { "issue": ("https://github.com/tox-dev/python-discovery/issues/%s", "#%s"), "pull": ("https://github.com/tox-dev/python-discovery/pull/%s", "PR #%s"), @@ -33,7 +39,7 @@ templates_path = [] source_suffix = ".rst" -exclude_patterns = ["_build", "changelog/*.rst"] +exclude_patterns = ["_build", "changelog/*"] main_doc = "index" pygments_style = "default" diff --git a/docs/index.rst b/docs/index.rst index 06a4590..ceea4da 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -47,3 +47,9 @@ repeated lookups are fast. :hidden: explanation + +.. toctree:: + :caption: Project + :hidden: + + changelog diff --git a/pyproject.toml b/pyproject.toml index 4332a6c..ad7dff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,8 @@ optional-dependencies.docs = [ "sphinx>=9.1", "sphinx-autodoc-typehints>=3.6.3", "sphinxcontrib-mermaid>=2", + "sphinxcontrib-towncrier>=0.4", + "towncrier>=25.8", ] optional-dependencies.testing = [ "covdefaults>=2.3", @@ -64,6 +66,14 @@ urls.Homepage = "https://github.com/tox-dev/python-discovery" urls.Source = "https://github.com/tox-dev/python-discovery" urls.Tracker = "https://github.com/tox-dev/python-discovery/issues" +[dependency-groups] +release = [ + "gitpython>=3.1.46", + "packaging>=26", + "pre-commit>=4.5.1", + "towncrier>=25.8", +] + [tool.hatch] version.source = "vcs" @@ -98,6 +108,12 @@ lint.per-file-ignores."src/python_discovery/_py_info.py" = [ lint.per-file-ignores."src/python_discovery/_windows/_pep514.py" = [ "PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock ] +lint.per-file-ignores."tasks/**/*.py" = [ + "D", # release helper scripts, not API + "INP001", # no __init__.py in tasks directory + "S404", # subprocess import (release tooling) + "S603", # `subprocess` call: argv built from trusted inputs (version, gh CLI) +] lint.per-file-ignores."tests/**/*.py" = [ "D", # don't care about documentation in tests "FBT", # don't care about booleans as positional arguments in tests @@ -180,3 +196,21 @@ report.partial_branches = [ report.show_missing = true html.show_contexts = true html.skip_covered = false + +[tool.towncrier] +name = "python-discovery" +filename = "docs/changelog.rst" +directory = "docs/changelog" +title_format = false +issue_format = ":issue:`{issue}`" +template = "docs/changelog/template.jinja2" +type = [ + { directory = "breaking", name = "Backward incompatible changes", showcontent = true }, + { directory = "deprecation", name = "Deprecations (removal in next major release)", showcontent = true }, + { directory = "feature", name = "Features", showcontent = true }, + { directory = "bugfix", name = "Bug fixes", showcontent = true }, + { directory = "doc", name = "Improved documentation", showcontent = true }, + { directory = "packaging", name = "Packaging updates and notes for downstreams", showcontent = true }, + { directory = "contrib", name = "Contributor-facing changes", showcontent = true }, + { directory = "misc", name = "Miscellaneous internal changes", showcontent = true }, +] diff --git a/tasks/release.py b/tasks/release.py new file mode 100644 index 0000000..97a6123 --- /dev/null +++ b/tasks/release.py @@ -0,0 +1,167 @@ +"""Handles creating a release commit and tag, then publishes the GitHub release.""" + +from __future__ import annotations + +from pathlib import Path +from subprocess import CalledProcessError, check_call, run + +from git import Commit, Head, Remote, Repo, TagReference +from packaging.version import Version + +ROOT_SRC_DIR = Path(__file__).parents[1] + + +def main(version_str: str) -> None: + version = Version(version_str) + repo = Repo(str(ROOT_SRC_DIR)) + + if repo.is_dirty(): + msg = "Current repository is dirty. Please commit any changes and try again." + raise RuntimeError(msg) + upstream, release_branch = create_release_branch(repo, version) + main_pushed = False + tag_pushed = False + release_created = False + original_main_sha = upstream.refs.main.commit.hexsha + try: + release_commit = release_changelog(repo, version) + tag = tag_release_commit(release_commit, repo, version) + print("push release commit") # noqa: T201 + repo.git.push(upstream.name, f"{release_branch}:main", "-f") + main_pushed = True + print("push release tag") # noqa: T201 + repo.git.push(upstream.name, tag, "-f") + tag_pushed = True + create_github_release(version) + release_created = True + print("checkout main to new release and delete release branch") # noqa: T201 + repo.heads.main.checkout() + repo.delete_head(release_branch, force=True) + print("delete remote release branch") # noqa: T201 + repo.git.push(upstream.name, f":{release_branch}", "--no-verify") + upstream.fetch() + repo.git.reset("--hard", f"{upstream.name}/main") + print("All done!") # noqa: T201 + except Exception: + cleanup_failed_release( + repo, + upstream, + version, + release_branch, + original_main_sha, + release_created=release_created, + tag_pushed=tag_pushed, + main_pushed=main_pushed, + ) + raise + + +def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]: + print("create release branch from upstream main") # noqa: T201 + upstream = get_upstream(repo) + upstream.fetch() + branch_name = f"release-{version}" + release_branch = repo.create_head(branch_name, upstream.refs.main, force=True) + upstream.push(refspec=f"{branch_name}:{branch_name}", force=True) + release_branch.set_tracking_branch(upstream.refs[branch_name]) + release_branch.checkout() + return upstream, release_branch + + +def get_upstream(repo: Repo) -> Remote: + for remote in repo.remotes: + if any("tox-dev/python-discovery" in url for url in remote.urls): + return remote + msg = "could not find tox-dev/python-discovery remote" + raise RuntimeError(msg) + + +def release_changelog(repo: Repo, version: Version) -> Commit: + print("generate release commit") # noqa: T201 + check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607 + print("format changelog with pre-commit") # noqa: T201 + changelog_path = ROOT_SRC_DIR / "docs" / "changelog.rst" + try: + check_call(["pre-commit", "run", "--files", str(changelog_path)], cwd=str(ROOT_SRC_DIR)) # noqa: S607 + except CalledProcessError: + print("pre-commit made formatting changes, staging them") # noqa: T201 + repo.index.add([str(changelog_path)]) + return repo.index.commit(f"release {version}") + + +def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference: + print("tag release commit") # noqa: T201 + version_str = str(version) + if version_str in {tag.name for tag in repo.tags}: + print(f"delete existing tag {version_str}") # noqa: T201 + repo.delete_tag(repo.tags[version_str]) + print(f"create tag {version_str}") # noqa: T201 + return repo.create_tag(version_str, ref=release_commit.hexsha, force=True) + + +def create_github_release(version: Version) -> None: + print("create github release") # noqa: T201 + version_str = str(version) + try: + result = run( + ["gh", "release", "create", version_str, "--title", f"v{version_str}", "--generate-notes"], # noqa: S607 + cwd=str(ROOT_SRC_DIR), + capture_output=True, + text=True, + check=True, + ) + if result.stdout: + print(result.stdout) # noqa: T201 + except CalledProcessError as e: + print(f"gh release create failed with exit code {e.returncode}") # noqa: T201 + if e.stdout: + print(f"stdout: {e.stdout}") # noqa: T201 + if e.stderr: + print(f"stderr: {e.stderr}") # noqa: T201 + raise + + +def cleanup_failed_release( # noqa: PLR0913 + repo: Repo, + upstream: Remote, + version: Version, + release_branch: Head, + original_main_sha: str, + *, + release_created: bool, + tag_pushed: bool, + main_pushed: bool, +) -> None: + print("Release failed! Cleaning up...") # noqa: T201 + if release_created: + print(f"Deleting GitHub release {version}") # noqa: T201 + try: + check_call(["gh", "release", "delete", str(version), "--yes"], cwd=str(ROOT_SRC_DIR)) # noqa: S607 + except Exception as cleanup_error: # noqa: BLE001 + print(f"Warning: Failed to delete GitHub release: {cleanup_error}") # noqa: T201 + if tag_pushed: + print(f"Deleting remote tag {version}") # noqa: T201 + try: + repo.git.push(upstream.name, f":refs/tags/{version}", "--no-verify") + except Exception as cleanup_error: # noqa: BLE001 + print(f"Warning: Failed to delete remote tag: {cleanup_error}") # noqa: T201 + if main_pushed: + print(f"Reverting main to {original_main_sha[:8]}") # noqa: T201 + try: + repo.git.push(upstream.name, f"{original_main_sha}:main", "-f", "--no-verify") + except Exception as cleanup_error: # noqa: BLE001 + print(f"Warning: Failed to revert main: {cleanup_error}") # noqa: T201 + print("Deleting remote release branch") # noqa: T201 + try: + repo.git.push(upstream.name, f":{release_branch}", "--no-verify") + except Exception as cleanup_error: # noqa: BLE001 + print(f"Warning: Failed to delete remote branch: {cleanup_error}") # noqa: T201 + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(prog="release") + parser.add_argument("--version", required=True) + options = parser.parse_args() + main(options.version) diff --git a/tox.toml b/tox.toml index 3b57209..6e5781c 100644 --- a/tox.toml +++ b/tox.toml @@ -36,12 +36,12 @@ commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure"]] [env."type-3.8"] description = "run type check on code base (3.8)" -deps = ["ty==0.0.17"] +deps = ["gitpython>=3.1.46", "packaging>=26", "ty==0.0.17"] commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.8", "."]] [env."type-3.14"] description = "run type check on code base (3.14)" -deps = ["ty==0.0.17"] +deps = ["gitpython>=3.1.46", "packaging>=26", "ty==0.0.17"] commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.14", "."]] [env.docs] @@ -71,6 +71,13 @@ commands = [ ["check-wheel-contents", "--no-config", "{env_tmp_dir}"], ] +[env.release] +description = "do a release, required posargs of the version number" +skip_install = true +dependency_groups = ["release"] +pass_env = ["GH_TOKEN"] +commands = [["python", "{tox_root}/tasks/release.py", "--version", "{posargs}"]] + [env.dev] description = "generate a DEV environment" package = "editable"