├── .github ├── CODEOWNERS ├── FUNDING.yaml ├── SECURITY.md ├── dependabot.yaml ├── release.yaml └── workflows │ ├── check.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── pyproject.toml ├── src └── tox_gh │ ├── __init__.py │ ├── plugin.py │ └── py.typed ├── tests ├── conftest.py ├── test_tox_gh.py └── test_version.py └── tox.toml /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gaborbernat 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/tox-gh 2 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.3.1 + | :white_check_mark: | 8 | | < 1.3.1 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift 13 | will coordinate the fix and disclosure. 14 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot[bot] 5 | - pre-commit-ci[bot] 6 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: ["main"] 6 | tags-ignore: ["**"] 7 | pull_request: 8 | schedule: 9 | - cron: "0 8 * * *" 10 | 11 | concurrency: 12 | group: check-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | name: test with ${{ matrix.env }} on ${{ matrix.os }} 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | env: 23 | - "3.14" 24 | - "3.13" 25 | - "3.12" 26 | - "3.11" 27 | - "3.10" 28 | os: 29 | - ubuntu-latest 30 | - macos-latest 31 | - windows-latest 32 | steps: 33 | - uses: actions/checkout@v5 34 | with: 35 | fetch-depth: 0 36 | - name: Install the latest version of uv 37 | uses: astral-sh/setup-uv@v7 38 | with: 39 | enable-cache: true 40 | cache-dependency-glob: "pyproject.toml" 41 | github-token: ${{ secrets.GITHUB_TOKEN }} 42 | - name: Add .local/bin to Windows PATH 43 | if: runner.os == 'Windows' 44 | shell: bash 45 | run: echo "$USERPROFILE/.local/bin" >> $GITHUB_PATH 46 | - name: Install tox 47 | run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv --with tox-gh@. 48 | - name: Install Python 49 | if: matrix.env != '3.14' 50 | run: uv python install --python-preference only-managed ${{ matrix.env }} 51 | - name: Setup test suite 52 | run: tox run -vv --notest --skip-missing-interpreters false 53 | env: 54 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 55 | - name: Run test suite 56 | run: tox run --skip-pkg-install 57 | env: 58 | PYTEST_ADDOPTS: "-vv --durations=20" 59 | DIFF_AGAINST: HEAD 60 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | env: 7 | dists-artifact-name: python-package-distributions 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | with: 15 | fetch-depth: 0 16 | - name: Install the latest version of uv 17 | uses: astral-sh/setup-uv@v7 18 | with: 19 | enable-cache: true 20 | cache-dependency-glob: "pyproject.toml" 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build package 23 | run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v5 26 | with: 27 | name: ${{ env.dists-artifact-name }} 28 | path: dist/* 29 | 30 | release: 31 | needs: 32 | - build 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: release 36 | url: https://pypi.org/project/tox-gh/${{ github.ref_name }} 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Download all the dists 41 | uses: actions/download-artifact@v6 42 | with: 43 | name: ${{ env.dists-artifact-name }} 44 | path: dist/ 45 | - name: Publish to PyPI 46 | uses: pypa/gh-action-pypi-publish@v1.13.0 47 | with: 48 | attestations: true 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /magic 2 | .idea 3 | *.egg-info 4 | .tox/ 5 | .coverage* 6 | coverage.xml 7 | .*_cache 8 | __pycache__ 9 | **.pyc 10 | /build 11 | dist 12 | src/tox_gh/version.py 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: 0.34.1 9 | hooks: 10 | - id: check-github-workflows 11 | args: ["--verbose"] 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | additional_dependencies: ["tomli>=2.3"] 17 | - repo: https://github.com/tox-dev/pyproject-fmt 18 | rev: "v2.11.0" 19 | hooks: 20 | - id: pyproject-fmt 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: "v0.14.2" 23 | hooks: 24 | - id: ruff-format 25 | - id: ruff-check 26 | args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] 27 | - repo: https://github.com/rbubley/mirrors-prettier 28 | rev: "v3.6.2" 29 | hooks: 30 | - id: prettier 31 | args: ["--print-width=120", "--prose-wrap=always"] 32 | - repo: meta 33 | hooks: 34 | - id: check-hooks-apply 35 | - id: check-useless-excludes 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish, 5 | distribute, sublicense, and/or sell copies of the Software, and to 6 | permit persons to whom the Software is furnished to do so, subject to 7 | the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included 10 | in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 15 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 16 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tox-gh 2 | 3 | [![PyPI version](https://badge.fury.io/py/tox-gh.svg)](https://badge.fury.io/py/tox-gh) 4 | [![PyPI Supported Python Versions](https://img.shields.io/pypi/pyversions/tox-gh.svg)](https://pypi.python.org/pypi/tox-gh/) 5 | [![check](https://github.com/tox-dev/tox-gh/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/tox-gh/actions/workflows/check.yaml) 6 | [![Downloads](https://static.pepy.tech/badge/tox-gh/month)](https://pepy.tech/project/tox-gh) 7 | 8 | **tox-gh** is a tox plugin, which helps run tox on GitHub Actions with multiple different Python versions on multiple 9 | workers in parallel. 10 | 11 | ## Features 12 | 13 | When running tox on GitHub Actions, tox-gh: 14 | 15 | - detects which environment to run based on configurations (or bypasses detection and sets it explicitly via the 16 | `TOX_GH_MAJOR_MINOR` environment variable), 17 | - provides utilities such as 18 | [grouping log lines](https://github.com/actions/toolkit/blob/main/docs/commands.md#group-and-ungroup-log-lines). 19 | 20 | ## Usage 21 | 22 | 1. Add configurations under `[gh]` section along with your tox configuration. 23 | 2. Install `tox-gh` package in the GitHub Actions workflow before running `tox` command. 24 | 25 | ## Examples 26 | 27 | ### Basic Example 28 | 29 | Add `[gh]` section to the same file as tox configuration. 30 | 31 | If you're using `tox.ini`: 32 | 33 | ```ini 34 | [gh] 35 | python = 36 | 3.14t = 3.14t 37 | 3.14 = 3.14, type, dev, pkg_meta 38 | 3.13 = 3.13 39 | 3.12 = 3.12 40 | ``` 41 | 42 | For `tox.toml`: 43 | 44 | ```toml 45 | [gh.python] 46 | "3.14t" = ["3.14t"] 47 | "3.14" = ["3.14", "type", "pkg_meta"] 48 | "3.13" = ["3.13"] 49 | "3.12" = ["3.12"] 50 | ``` 51 | 52 | For `pyproject.toml`: 53 | 54 | ```toml 55 | [tool.tox.gh.python] 56 | "3.14t" = ["3.14t"] 57 | "3.14" = ["3.14", "type", "pkg_meta"] 58 | "3.13" = ["3.13"] 59 | "3.12" = ["3.12"] 60 | ``` 61 | 62 | This will run a different set of tox environments on different Python versions set up via GitHub `setup-python` action: 63 | 64 | - on Python 3.14t job, tox runs `3.14t` environment, 65 | - on Python 3.14 job, tox runs `3.14`, `type` and `pkg_meta` environments, 66 | - on Python 3.13 job, tox runs `3.13` environment, 67 | - on Python 3.12 job, tox runs `3.12` environment, 68 | 69 | #### Workflow Configuration 70 | 71 | A bare-bones example would be `.github/workflows/check.yaml`: 72 | 73 | ```yaml 74 | jobs: 75 | test: 76 | name: test with ${{ matrix.env }} on ${{ matrix.os }} 77 | runs-on: ${{ matrix.os }} 78 | strategy: 79 | fail-fast: false 80 | matrix: 81 | env: 82 | - "3.14t" 83 | - "3.14" 84 | - "3.13" 85 | os: 86 | - ubuntu-latest 87 | - macos-latest 88 | steps: 89 | - uses: actions/checkout@v5 90 | - name: Install the latest version of uv 91 | uses: astral-sh/setup-uv@v7 92 | - name: Install tox 93 | run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv --with tox-gh 94 | - name: Install Python 95 | if: matrix.env != '3.14' 96 | run: uv python install --python-preference only-managed ${{ matrix.env }} 97 | - name: Setup test suite 98 | run: tox run -vv --notest --skip-missing-interpreters false 99 | env: 100 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 101 | - name: Run test suite 102 | run: tox run --skip-pkg-install 103 | env: 104 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 105 | ``` 106 | 107 | A more exhaustive example would be `.github/workflows/check.yaml`: 108 | 109 | ```yaml 110 | name: check 111 | on: 112 | workflow_dispatch: 113 | push: 114 | branches: ["main"] 115 | tags-ignore: ["**"] 116 | pull_request: 117 | schedule: # Runs at 8 AM every day 118 | - cron: "0 8 * * *" 119 | 120 | concurrency: 121 | group: check-${{ github.ref }} 122 | cancel-in-progress: true 123 | 124 | jobs: 125 | test: 126 | name: test with ${{ matrix.env }} on ${{ matrix.os }} 127 | runs-on: ${{ matrix.os }} 128 | strategy: 129 | fail-fast: false 130 | matrix: 131 | env: 132 | - "3.14t" 133 | - "3.14" 134 | - "3.13" 135 | os: 136 | - ubuntu-latest 137 | - macos-latest 138 | - windows-latest 139 | steps: 140 | - uses: actions/checkout@v5 141 | with: 142 | fetch-depth: 0 143 | - name: Install the latest version of uv 144 | uses: astral-sh/setup-uv@v7 145 | with: 146 | enable-cache: true 147 | cache-dependency-glob: "pyproject.toml" 148 | github-token: ${{ secrets.GITHUB_TOKEN }} 149 | - name: Add .local/bin to Windows PATH 150 | if: runner.os == 'Windows' 151 | shell: bash 152 | run: echo "$USERPROFILE/.local/bin" >> $GITHUB_PATH 153 | - name: Install tox 154 | run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv --with tox-gh 155 | - name: Install Python 156 | if: matrix.env != '3.14' 157 | run: uv python install --python-preference only-managed ${{ matrix.env }} 158 | - name: Setup test suite 159 | run: tox run -vv --notest --skip-missing-interpreters false 160 | env: 161 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 162 | - name: Run test suite 163 | run: tox run --skip-pkg-install 164 | env: 165 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 166 | ``` 167 | 168 | ## FAQ 169 | 170 | - When a list of environments to run is specified explicitly via `-e` option or `TOXENV` environment variable `tox-gh` 171 | respects the given environments and simply runs the given environments without enforcing its configuration. 172 | - The plugin only activates if the environment variable `GITHUB_ACTIONS` is `true`. 173 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.5", 5 | "hatchling>=1.27", 6 | ] 7 | 8 | [project] 9 | name = "tox-gh" 10 | description = "Seamless integration of tox into GitHub Actions." 11 | readme = "README.md" 12 | keywords = [ 13 | "environments", 14 | "isolated", 15 | "testing", 16 | "virtual", 17 | ] 18 | license = "MIT" 19 | maintainers = [ 20 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 21 | ] 22 | requires-python = ">=3.10" 23 | classifiers = [ 24 | "Development Status :: 5 - Production/Stable", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Programming Language :: Python :: 3.13", 34 | "Programming Language :: Python :: 3.14", 35 | "Topic :: Internet", 36 | "Topic :: Software Development :: Libraries", 37 | "Topic :: System", 38 | ] 39 | dynamic = [ 40 | "version", 41 | ] 42 | dependencies = [ 43 | "tox>=4.31", 44 | ] 45 | optional-dependencies.testing = [ 46 | "covdefaults>=2.3", 47 | "devpi-process>=1.0.2", 48 | "diff-cover>=9.7.1", 49 | "pytest>=8.4.2", 50 | "pytest-cov>=7", 51 | "pytest-mock>=3.15.1", 52 | ] 53 | urls.Documentation = "https://github.com/tox-dev/tox-gh#tox-gh" 54 | urls.Homepage = "https://github.com/tox-dev/tox-gh" 55 | urls.Source = "https://github.com/tox-dev/tox-gh" 56 | urls.Tracker = "https://github.com/tox-dev/tox-gh/issues" 57 | entry-points.tox.tox-gh = "tox_gh.plugin" 58 | 59 | [tool.hatch] 60 | build.hooks.vcs.version-file = "src/tox_gh/version.py" 61 | build.targets.sdist.include = [ 62 | "/src", 63 | "/tests", 64 | ] 65 | version.source = "vcs" 66 | 67 | [tool.black] 68 | line-length = 120 69 | 70 | [tool.ruff] 71 | line-length = 120 72 | format.preview = true 73 | format.docstring-code-line-length = 100 74 | format.docstring-code-format = true 75 | lint.select = [ 76 | "ALL", 77 | ] 78 | lint.ignore = [ 79 | "COM812", # Conflict with formatter 80 | "CPY", # No copyright statements 81 | "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible 82 | "D205", # 1 blank line required between summary line and description 83 | "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible 84 | "D301", # Use `r"""` if any backslashes in a docstring 85 | "D401", # First line of docstring should be in imperative mood 86 | "DOC", # not supported 87 | "ISC001", # Conflict with formatter 88 | "LOG015", # call on root logger 89 | "S104", # Possible binding to all interface 90 | ] 91 | lint.per-file-ignores."tests/**/*.py" = [ 92 | "D", # don't care about documentation in tests 93 | "FBT", # don't care about booleans as positional arguments in tests 94 | "INP001", # no implicit namespace 95 | "PLR2004", # Magic value used in comparison, consider replacing with a constant variable 96 | "S101", # asserts allowed in tests 97 | "S603", # `subprocess` call: check for execution of untrusted input 98 | ] 99 | lint.isort = { known-first-party = [ 100 | "tox_gh", 101 | "tests", 102 | ], required-imports = [ 103 | "from __future__ import annotations", 104 | ] } 105 | lint.preview = true 106 | 107 | [tool.codespell] 108 | builtin = "clear,usage,en-GB_to_en-US" 109 | write-changes = true 110 | count = true 111 | 112 | [tool.coverage] 113 | html.show_contexts = true 114 | html.skip_covered = false 115 | paths.source = [ 116 | "src", 117 | ".tox/*/lib/*/site-packages", 118 | ".tox\\*\\Lib\\site-packages", 119 | "**/src", 120 | "**\\src", 121 | ] 122 | paths.other = [ 123 | ".", 124 | "*/tox_gh", 125 | "*\\tox_gh", 126 | ] 127 | report.fail_under = 96 128 | run.parallel = true 129 | run.plugins = [ 130 | "covdefaults", 131 | ] 132 | 133 | [tool.mypy] 134 | python_version = "3.11" 135 | show_error_codes = true 136 | strict = true 137 | overrides = [ 138 | { module = [ 139 | "virtualenv.*", 140 | ], ignore_missing_imports = true }, 141 | ] 142 | -------------------------------------------------------------------------------- /src/tox_gh/__init__.py: -------------------------------------------------------------------------------- 1 | """GitHub Actions integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .version import version as __version__ 6 | 7 | __all__ = [ 8 | "__version__", 9 | ] 10 | -------------------------------------------------------------------------------- /src/tox_gh/plugin.py: -------------------------------------------------------------------------------- 1 | """GitHub Actions integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import shutil 9 | import sys 10 | import threading 11 | from typing import TYPE_CHECKING, Any 12 | 13 | from tox.config.loader.memory import MemoryLoader 14 | from tox.config.loader.section import Section 15 | from tox.config.sets import ConfigSet 16 | from tox.config.types import EnvList 17 | from tox.execute import Outcome 18 | from tox.plugin import impl 19 | from virtualenv.discovery.py_info import PythonInfo 20 | 21 | if TYPE_CHECKING: 22 | from tox.session.state import State 23 | from tox.tox_env.api import ToxEnv 24 | 25 | GITHUB_STEP_SUMMARY = os.getenv("GITHUB_STEP_SUMMARY") 26 | WILL_RUN_MULTIPLE_ENVS = False 27 | 28 | 29 | def is_running_on_actions() -> bool: 30 | """:return: True if running on GitHub Actions platform""" 31 | # https://docs.github.com/en/actions/reference/environment-variables#default-environment-variables 32 | return os.environ.get("GITHUB_ACTIONS") == "true" 33 | 34 | 35 | def get_python_version_keys() -> list[str]: 36 | """:return: python spec for the python interpreter""" 37 | if os.environ.get("TOX_GH_MAJOR_MINOR"): 38 | major_minor_version = os.environ["TOX_GH_MAJOR_MINOR"] 39 | return [major_minor_version, major_minor_version.split(".")[0]] 40 | python_exe = shutil.which("python") or sys.executable 41 | info = PythonInfo.from_exe(exe=python_exe) 42 | major_version = str(info.version_info[0]) 43 | major_minor_version = ".".join([str(i) for i in info.version_info[:2]]) 44 | if info.implementation == "PyPy": 45 | return [f"pypy-{major_minor_version}", f"pypy-{major_version}", f"pypy{major_version}"] 46 | if hasattr(sys, "pyston_version_info"): # Pyston 47 | return [f"piston-{major_minor_version}", f"pyston-{major_version}"] 48 | # Assume this is running on CPython 49 | return [major_minor_version, major_version] 50 | 51 | 52 | class GhActionsConfigSet(ConfigSet): 53 | """GitHub Actions config set.""" 54 | 55 | def register_config(self) -> None: 56 | """Register the configurations.""" 57 | self.add_config( 58 | "python", 59 | of_type=dict[str, EnvList], 60 | default={}, 61 | desc="python version to mapping", 62 | ) 63 | 64 | 65 | @impl 66 | def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: 67 | """ 68 | Add core configuration flags. 69 | 70 | :param core_conf: the core configuration 71 | :param state: tox state object 72 | """ 73 | global WILL_RUN_MULTIPLE_ENVS # noqa: PLW0603 74 | 75 | core_conf.add_constant(keys="is_on_gh_action", desc="flag for running on Github", value=is_running_on_actions()) 76 | 77 | bail_reason = None 78 | if not core_conf["is_on_gh_action"]: 79 | bail_reason = "tox is not running in GitHub Actions" 80 | elif getattr(state.conf.options.env, "is_default_list", False) is False: 81 | bail_reason = f"envlist is explicitly given via {'TOXENV' if os.environ.get('TOXENV') else '-e flag'}" 82 | if bail_reason: 83 | logging.debug("tox-gh won't override envlist because %s", bail_reason) 84 | return 85 | 86 | logging.warning("running tox-gh") 87 | gh_config = state.conf.get_section_config(Section(None, "gh"), base=[], of_type=GhActionsConfigSet, for_env=None) 88 | python_mapping: dict[str, EnvList] = gh_config["python"] 89 | 90 | env_list = next((python_mapping[i] for i in get_python_version_keys() if i in python_mapping), None) 91 | if env_list is not None: # override the env_list core configuration with our values 92 | logging.warning("tox-gh set %s", ", ".join(env_list)) 93 | state.conf.core.loaders.insert(0, MemoryLoader(env_list=env_list)) 94 | WILL_RUN_MULTIPLE_ENVS = len(env_list.envs) > 1 95 | 96 | 97 | _STATE = threading.local() 98 | 99 | 100 | @impl 101 | def tox_on_install(tox_env: ToxEnv, arguments: Any, section: str, of_type: str) -> None: # noqa: ANN401, ARG001 102 | """ 103 | Run before installing to prepare an environment. 104 | 105 | :param tox_env: the tox environment 106 | :param arguments: installation arguments 107 | :param section: section of the installation 108 | :param of_type: type of the installation 109 | """ 110 | if tox_env.core["is_on_gh_action"]: 111 | installing = getattr(_STATE, "installing", False) 112 | if not installing: 113 | _STATE.installing = True 114 | print("::group::tox:install") # noqa: T201 115 | 116 | 117 | @impl 118 | def tox_before_run_commands(tox_env: ToxEnv) -> None: 119 | """ 120 | Run logic before tox run commands. 121 | 122 | :param tox_env: the tox environment 123 | """ 124 | if tox_env.core["is_on_gh_action"]: 125 | assert _STATE.installing # noqa: S101 126 | _STATE.installing = False 127 | print("::endgroup::") # noqa: T201 128 | print(f"::group::tox:{tox_env.name}") # noqa: T201 129 | 130 | 131 | @impl 132 | def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: list[Outcome]) -> None: # noqa: ARG001 133 | """ 134 | Run logic before after run commands. 135 | 136 | 137 | :param tox_env: the tox environment 138 | :param exit_code: command exit code 139 | :param outcomes: list of outcomes 140 | """ 141 | if tox_env.core["is_on_gh_action"]: 142 | print("::endgroup::") # noqa: T201 143 | if WILL_RUN_MULTIPLE_ENVS: 144 | write_to_summary(exit_code == Outcome.OK, tox_env.name) 145 | 146 | 147 | def write_to_summary(success: bool, message: str) -> None: # noqa: FBT001 148 | """Write a success or failure value to the GitHub step summary if it exists.""" 149 | if not GITHUB_STEP_SUMMARY: 150 | return 151 | summary_path = pathlib.Path(GITHUB_STEP_SUMMARY) 152 | success_str = ":white_check_mark:" if success else ":negative_squared_cross_mark:" 153 | with summary_path.open("a+", encoding="utf-8") as summary_file: 154 | print(f"{success_str}: {message}", file=summary_file) 155 | -------------------------------------------------------------------------------- /src/tox_gh/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox-gh/2a0c1993dcc5dde4610c4767e0fec4a325265212/src/tox_gh/py.typed -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | pytest_plugins = [ 4 | "tox.pytest", 5 | ] 6 | -------------------------------------------------------------------------------- /tests/test_tox_gh.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING 5 | from unittest.mock import ANY 6 | 7 | import pytest 8 | 9 | from tox_gh import plugin 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | from tox.pytest import MonkeyPatch, ToxProjectCreator 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def _clear_env_var(monkeypatch: MonkeyPatch) -> None: 19 | monkeypatch.delenv("TOX_GH_MAJOR_MINOR", raising=False) 20 | 21 | 22 | @pytest.fixture 23 | def summary_output_path(monkeypatch: MonkeyPatch, tmp_path: Path) -> Path: 24 | path = tmp_path / "gh_out" 25 | path.touch() 26 | monkeypatch.setattr(plugin, "GITHUB_STEP_SUMMARY", str(path)) 27 | return path 28 | 29 | 30 | def test_gh_not_in_actions(monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator) -> None: 31 | monkeypatch.delenv("GITHUB_ACTIONS", raising=False) 32 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) 33 | result = project.run("-vv") 34 | result.assert_success() 35 | assert "tox-gh won't override envlist because tox is not running in GitHub Actions" in result.out 36 | 37 | 38 | def test_gh_not_in_actions_quiet(monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator) -> None: 39 | monkeypatch.delenv("GITHUB_ACTIONS", raising=False) 40 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) 41 | result = project.run() 42 | result.assert_success() 43 | assert "tox-gh won't override envlist because tox is not running in GitHub Actions" not in result.out 44 | 45 | 46 | def test_gh_e_flag_set(monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator) -> None: 47 | monkeypatch.setenv("GITHUB_ACTIONS", "true") 48 | monkeypatch.delenv("TOXENV", raising=False) 49 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) 50 | result = project.run("-e", "py", "-vv") 51 | result.assert_success() 52 | assert "tox-gh won't override envlist because envlist is explicitly given via -e flag" in result.out 53 | 54 | 55 | def test_gh_toxenv_set(monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator) -> None: 56 | monkeypatch.setenv("GITHUB_ACTIONS", "true") 57 | monkeypatch.setenv("TOXENV", "py") 58 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) 59 | result = project.run("-vv") 60 | result.assert_success() 61 | assert "tox-gh won't override envlist because envlist is explicitly given via TOXENV" in result.out 62 | 63 | 64 | @pytest.mark.parametrize("via_env", [True, False]) 65 | def test_gh_ok( 66 | monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator, tmp_path: Path, summary_output_path: Path, via_env: bool 67 | ) -> None: 68 | if via_env: 69 | monkeypatch.setenv("TOX_GH_MAJOR_MINOR", f"{sys.version_info.major}.{sys.version_info.minor}") 70 | else: 71 | monkeypatch.setenv("PATH", "") 72 | empty_requirements = tmp_path / "empty.txt" 73 | empty_requirements.touch() 74 | monkeypatch.setenv("GITHUB_ACTIONS", "true") 75 | monkeypatch.delenv("TOXENV", raising=False) 76 | ini = f""" 77 | [testenv] 78 | package = editable 79 | deps = -r {empty_requirements} 80 | [gh] 81 | python = 82 | {sys.version_info[0]} = a, b 83 | """ 84 | project = tox_project({"tox.ini": ini}) 85 | result = project.run() 86 | result.assert_success() 87 | assert result.out.splitlines() == [ 88 | "ROOT: running tox-gh", 89 | "ROOT: tox-gh set a, b", 90 | "::group::tox:install", 91 | f"a: install_deps> python -I -m pip install -r {empty_requirements}", 92 | ANY, # pip install setuptools wheel 93 | ANY, # .pkg: _optional_hooks 94 | ANY, # .pkg: get_requires_for_build_editable 95 | ".pkg: freeze> python -m pip freeze --all", 96 | ANY, # freeze list 97 | ANY, # .pkg: build_editable 98 | ANY, # a: install_package 99 | "a: freeze> python -m pip freeze --all", 100 | ANY, # freeze list 101 | "::endgroup::", 102 | "::group::tox:a", 103 | "::endgroup::", 104 | ANY, # a finished 105 | "::group::tox:install", 106 | f"b: install_deps> python -I -m pip install -r {empty_requirements}", 107 | ANY, # b: install_package 108 | "b: freeze> python -m pip freeze --all", 109 | ANY, # freeze list 110 | "::endgroup::", 111 | "::group::tox:b", 112 | "::endgroup::", 113 | ANY, # a status 114 | ANY, # b status 115 | ANY, # outcome 116 | ] 117 | 118 | assert "a: OK" in result.out 119 | assert "b: OK" in result.out 120 | 121 | summary_text = summary_output_path.read_text(encoding="utf-8") 122 | assert ":white_check_mark:: a" in summary_text 123 | assert ":white_check_mark:: b" in summary_text 124 | 125 | 126 | def test_gh_fail(monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator, summary_output_path: Path) -> None: 127 | monkeypatch.setenv("GITHUB_ACTIONS", "true") 128 | monkeypatch.delenv("TOXENV", raising=False) 129 | ini = f""" 130 | [testenv] 131 | package = skip 132 | commands = python -c exit(1) 133 | [gh] 134 | python = 135 | {sys.version_info[0]} = a, b 136 | """ 137 | project = tox_project({"tox.ini": ini}) 138 | result = project.run() 139 | result.assert_failed() 140 | 141 | assert result.out.splitlines() == [ 142 | "ROOT: running tox-gh", 143 | "ROOT: tox-gh set a, b", 144 | "::group::tox:install", 145 | "a: freeze> python -m pip freeze --all", 146 | ANY, # freeze list 147 | "::endgroup::", 148 | "::group::tox:a", 149 | ANY, # "a: commands[0]> python -c 'exit(1)'", but without the quotes on Windows. 150 | ANY, # process details 151 | "::endgroup::", 152 | ANY, # a finished 153 | "::group::tox:install", 154 | "b: freeze> python -m pip freeze --all", 155 | ANY, # freeze list 156 | "::endgroup::", 157 | "::group::tox:b", 158 | ANY, # "b: commands[0]> python -c 'exit(1)'", but without the quotes on Windows. 159 | ANY, # b process details 160 | "::endgroup::", 161 | ANY, # a status 162 | ANY, # b status 163 | ANY, # outcome 164 | ] 165 | 166 | assert "a: FAIL code 1" in result.out 167 | assert "b: FAIL code 1" in result.out 168 | 169 | summary_text = summary_output_path.read_text(encoding="utf-8") 170 | assert ":negative_squared_cross_mark:: a" in summary_text 171 | assert ":negative_squared_cross_mark:: b" in summary_text 172 | 173 | 174 | def test_gh_single_env_ok(monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator, summary_output_path: Path) -> None: 175 | monkeypatch.setenv("GITHUB_ACTIONS", "true") 176 | monkeypatch.delenv("TOXENV", raising=False) 177 | ini = f""" 178 | [testenv] 179 | package = editable 180 | [gh] 181 | python = 182 | {sys.version_info[0]} = a 183 | """ 184 | project = tox_project({"tox.ini": ini}) 185 | result = project.run() 186 | result.assert_success() 187 | 188 | summary_text = summary_output_path.read_text(encoding="utf-8") 189 | assert len(summary_text) == 0 190 | 191 | 192 | def test_gh_single_env_fail( 193 | monkeypatch: MonkeyPatch, tox_project: ToxProjectCreator, summary_output_path: Path 194 | ) -> None: 195 | monkeypatch.setenv("GITHUB_ACTIONS", "true") 196 | monkeypatch.delenv("TOXENV", raising=False) 197 | ini = f""" 198 | [testenv] 199 | package = skip 200 | commands = python -c exit(1) 201 | [gh] 202 | python = 203 | {sys.version_info[0]} = a 204 | """ 205 | project = tox_project({"tox.ini": ini}) 206 | result = project.run() 207 | result.assert_failed() 208 | 209 | summary_text = summary_output_path.read_text(encoding="utf-8") 210 | assert len(summary_text) == 0 211 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def test_version() -> None: 5 | from tox_gh import __version__ # noqa: PLC0415 6 | 7 | assert __version__ 8 | -------------------------------------------------------------------------------- /tox.toml: -------------------------------------------------------------------------------- 1 | requires = ["tox>=4.31", "tox-uv>=1.28.1"] 2 | env_list = ["fix", "3.14", "3.13", "3.12", "3.11", "3.10", "type", "pkg_meta"] 3 | skip_missing_interpreters = true 4 | 5 | [env_run_base] 6 | description = "run the tests with pytest under {env_name}" 7 | package = "wheel" 8 | wheel_build_env = ".pkg" 9 | extras = ["testing"] 10 | pass_env = ["DIFF_AGAINST", "PYTEST_*", "TOX_GH_MAJOR_MINOR"] 11 | set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } 12 | set_env.COVERAGE_FILECOVERAGE_PROCESS_START = "{tox_root}{/}pyproject.toml" 13 | commands = [ 14 | [ 15 | "pytest", 16 | { replace = "posargs", extend = true, default = [ 17 | "--durations", 18 | "5", 19 | "--junitxml", 20 | "{work_dir}{/}junit.{env_name}.xml", 21 | "--no-cov-on-fail", 22 | "--cov", 23 | "{env_site_packages_dir}{/}tox_gh", 24 | "--cov", 25 | "{tox_root}{/}tests", 26 | "--cov-config", 27 | "{tox_root}{/}pyproject.toml", 28 | "--cov-context", 29 | "test", 30 | "--cov-report", 31 | "term-missing:skip-covered", 32 | "--cov-report", 33 | "html:{env_tmp_dir}{/}htmlcov", 34 | "--cov-report", 35 | "xml:{work_dir}{/}coverage.{env_name}.xml", 36 | "tests", 37 | "--run-integration", 38 | ] }, 39 | ], 40 | [ 41 | "diff-cover", 42 | "--compare-branch", 43 | { replace = "env", name = "DIFF_AGAINST", default = "origin/main" }, 44 | "{work_dir}{/}coverage.{env_name}.xml", 45 | ], 46 | ] 47 | 48 | [env.fix] 49 | description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" 50 | skip_install = true 51 | deps = ["pre-commit-uv>=4.1.3"] 52 | pass_env = [{ replace = "ref", of = ["env_run_base", "pass_env"], extend = true }, "PROGRAMDATA"] 53 | commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs" }]] 54 | 55 | [env.type] 56 | description = "run type check on code base" 57 | deps = ["mypy==1.11.2"] 58 | commands = [["mypy", "src{/}tox_gh"], ["mypy", "tests"]] 59 | 60 | [env.pkg_meta] 61 | description = "check that the long description is valid" 62 | skip_install = true 63 | deps = ["check-wheel-contents>=0.6", "twine>=5.1.1", "uv>=0.4.17"] 64 | commands = [ 65 | [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "."], 66 | [ "twine", "check", "{env_tmp_dir}{/}*" ], 67 | [ "check-wheel-contents", "--no-config", "{env_tmp_dir}" ], 68 | ] 69 | 70 | [env.release] 71 | description = "do a release, required posargs of the version number" 72 | skip_install = true 73 | deps = ["gitpython>=3.1.43", "packaging>=24.1", "towncrier>=24.8"] 74 | commands = [["python", "{tox_root}/tasks/release.py", "--version", "{posargs}"]] 75 | 76 | [env.dev] 77 | description = "dev environment with all deps at {envdir}" 78 | package = "editable" 79 | deps = [ 80 | { replace = "ref", of = [ "env", "release", "deps"], extend = true }, 81 | { replace = "ref", of = [ "requires"], extend = true }, 82 | ] 83 | extras = ["docs", "testing"] 84 | commands = [["uv", "pip", "tree"], ["python", "-c", 'print(r"{env_python}")']] 85 | 86 | [gh.python] 87 | "3.14" = ["3.13", "type", "pkg_meta"] 88 | "3.13" = ["3.13"] 89 | "3.12" = ["3.12"] 90 | "3.11" = ["3.11"] 91 | "3.10" = ["3.10"] 92 | --------------------------------------------------------------------------------