Repository: wntrblm/nox (python)

Issue:
Add functionality similar to pytest.mark
**How would this feature be useful?**
It can simplify filtering specific sessions to be run. It can simplify writing long commands like `nox -s tests, lint, type, ...`. It can also extend the functionality of `nox.options.sessions`.

**Describe the solution you'd like**
I have two ideas how this can be done.
Basically it can be run by: 
`nox -m [mark-name]` if feature called "Marks" as in pytest,
`nox -t [tag-name]` if feature called "Tags".
And nox will run only sessions annotated by this mark/tag.

**1st option**
In `noxfile.py` it can be configured either by:
```python
@nox.mark("mark_name")
@nox.session()
def ...
``` 
or
 ```python
@nox.tag("tag_name")
@nox.tag("other_tag_name")
@nox.session()
def ...
```

**2nd option**
```python
nox.options.tags = {
    "default": ["session1", "session2"],  # this might replace `nox.options.sessions`
    "tag1": ["session1", "session4"],
    "tag2": ["session2", "session3"],
    etc.
}
```

Personally I prefer the 2nd option, I think it makes it more clear and concise.
And also I prefer "Tags" over "Marks" name, IMO "Tags" more precisely describe what it is. On the other hand, "Marks" might be known for those who use pytest.

Pull request description:
feat: Add session tags
Closes #339 

Patch:
diff --git a/.github/action_helper.py b/.github/action_helper.py
new file mode 100644
index 0000000..569d10b
--- /dev/null
+++ b/.github/action_helper.py
@@ -0,0 +1,74 @@
+import sys
+
+
+def filter_version(version: str) -> str:
+    """return python 'major.minor'"""
+
+    # remove interpreter prefix
+    if version.startswith("pypy-"):
+        version_ = version[5:]
+    elif version.startswith("pypy"):
+        version_ = version[4:]
+    else:
+        version_ = version
+
+    # remove extra specifier e.g. "3.11-dev" => "3.11"
+    version_ = version_.split("-")[0]
+
+    version_parts = version_.split(".")
+    if len(version_parts) < 2:
+        raise ValueError(f"invalid version: {version}")
+    if not version_parts[0].isdigit():
+        raise ValueError(f"invalid major python version: {version}")
+    if not version_parts[1].isdigit():
+        raise ValueError(f"invalid minor python version: {version}")
+    return ".".join(version_parts[:2])
+
+
+def setup_action(input_: str) -> None:
+    versions = [version.strip() for version in input_.split(",") if version.strip()]
+
+    pypy_versions = [version for version in versions if version.startswith("pypy")]
+    pypy_versions_filtered = [filter_version(version) for version in pypy_versions]
+    if len(pypy_versions) != len(set(pypy_versions_filtered)):
+        raise ValueError(
+            "multiple versions specified for the same 'major.minor' PyPy interpreter:"
+            f" {pypy_versions}"
+        )
+
+    cpython_versions = [version for version in versions if version not in pypy_versions]
+    cpython_versions_filtered = [
+        filter_version(version) for version in cpython_versions
+    ]
+    if len(cpython_versions) != len(set(cpython_versions_filtered)):
+        raise ValueError(
+            "multiple versions specified for the same 'major.minor' CPython"
+            f" interpreter: {cpython_versions}"
+        )
+
+    # cpython shall be installed last because
+    # other interpreters also define pythonX.Y symlinks.
+    versions = pypy_versions + cpython_versions
+
+    # we want to install python 3.10 last to ease nox set-up
+    if "3.10" in cpython_versions_filtered:
+        index = cpython_versions_filtered.index("3.10")
+        index = versions.index(cpython_versions[index])
+        cpython_310 = versions.pop(index)
+        versions.append(cpython_310)
+    else:
+        # add this to install nox
+        versions.append("3.10")
+
+    if len(versions) > 20:
+        raise ValueError(f"too many interpreters to install: {len(versions)} > 20")
+
+    print(f"::set-output name=interpreter_count::{len(versions)}")
+    for i, version in enumerate(versions):
+        print(f"::set-output name=interpreter_{i}::{version}")
+
+
+if __name__ == "__main__":
+    if len(sys.argv) != 2:
+        raise AssertionError(f"invalid arguments: {sys.argv}")
+    setup_action(sys.argv[1])
diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index 89d6e52..00215d2 100644
--- a/.github/workflows/action.yml
+++ b/.github/workflows/action.yml
@@ -20,3 +20,11 @@ jobs:
       - uses: actions/checkout@v3
       - uses: ./
       - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_default_tests
+  action-all-tests:
+    runs-on: windows-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: ./
+        with:
+          python-versions: "2.7.18, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11-dev, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9-v7.3.9"
+      - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_all_tests
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a4b5bd9..7a8e3d8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,12 +29,14 @@ repos:
     hooks:
       - id: isort
         args: ["-a", "from __future__ import annotations"]
+        exclude: ^.github/action_helper.py$
 
   - repo: https://github.com/asottile/pyupgrade
     rev: v2.32.0
     hooks:
       - id: pyupgrade
         args: [--py37-plus]
+        exclude: ^.github/action_helper.py$
 
   - repo: https://github.com/tox-dev/pyproject-fmt
     rev: "0.3.3"
diff --git a/action.yml b/action.yml
index deb2cb2..9e4443a 100644
--- a/action.yml
+++ b/action.yml
@@ -1,5 +1,10 @@
 name: Setup Nox
-description: 'Prepares all python versions for nox'
+description: "Prepares all python versions for nox"
+inputs:
+  python-versions:
+    description: "comma-separated list of python versions to install"
+    required: false
+    default: "3.7, 3.8, 3.9, 3.10, pypy-3.7, pypy-3.8, pypy-3.9"
 branding:
   icon: package
   color: blue
@@ -7,28 +12,91 @@ branding:
 runs:
   using: composite
   steps:
+    - name: "Validate input"
+      id: helper
+      run: ${{ runner.os == 'Windows' && 'python' || 'python3' }} '${{ github.action_path }}/.github/action_helper.py' '${{ inputs.python-versions }}'
+      shell: bash
+
     - uses: actions/setup-python@v4
       with:
-        python-version: "pypy-3.7"
+        python-version: "${{ steps.helper.outputs.interpreter_0 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 0 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "pypy-3.8"
+        python-version: "${{ steps.helper.outputs.interpreter_1 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 1 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "pypy-3.9"
-
+        python-version: "${{ steps.helper.outputs.interpreter_2 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 2 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_3 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 3 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_4 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 4 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_5 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 5 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_6 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 6 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_7 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 7 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_8 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 8 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_9 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 9 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_10 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 10 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_11 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 11 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_12 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 12 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_13 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 13 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_14 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 14 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_15 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 15 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.7"
+        python-version: "${{ steps.helper.outputs.interpreter_16 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 16 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.8"
+        python-version: "${{ steps.helper.outputs.interpreter_17 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 17 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.9"
+        python-version: "${{ steps.helper.outputs.interpreter_18 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 18 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.10"
+        python-version: "${{ steps.helper.outputs.interpreter_19 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 19 }}
 
     - name: "Install nox"
       # --python "$(which python)" => always use the last setup-python version to install nox.
diff --git a/docs/config.rst b/docs/config.rst
index 4e720f1..ff4a58c 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -429,6 +429,7 @@ The following options can be specified in the Noxfile:
 * ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions <opt-sessions-pythons-and-keywords>`. If set to an empty list, no sessions will be run if no sessions were given on the command line, and the list of available sessions will be shown instead.
 * ``nox.options.pythons`` is equivalent to specifying :ref:`-p or --pythons <opt-sessions-pythons-and-keywords>`.
 * ``nox.options.keywords`` is equivalent to specifying :ref:`-k or --keywords <opt-sessions-pythons-and-keywords>`.
+* ``nox.options.tags`` is equivalent to specifying :ref:`-t or --tags <opt-sessions-pythons-and-keywords>`.
 * ``nox.options.default_venv_backend`` is equivalent to specifying :ref:`-db or --default-venv-backend <opt-default-venv-backend>`.
 * ``nox.options.force_venv_backend`` is equivalent to specifying :ref:`-fb or --force-venv-backend <opt-force-venv-backend>`.
 * ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs <opt-reuse-existing-virtualenvs>`. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation.
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 83bc190..918f829 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -29,7 +29,23 @@ Either way, Nox is usually installed *globally*, similar to ``tox``, ``pip``, an
 
 If you're interested in running ``nox`` within `docker`_, you can use the `thekevjames/nox images`_ on DockerHub which contain builds for all ``nox`` versions and all supported ``python`` versions. Nox is also supported via ``pipx run nox`` in the `manylinux images`_.
 
-If you want to run ``nox`` within `GitHub Actions`_, you can use the ``wntrblm/nox`` action, which installs the latest ``nox`` and makes available all active CPython and PyPY versions provided by the GitHub Actions environment. You can safely combine this with with ``setup-python`` for past end-of-life or development versions of Python, as well.
+If you want to run ``nox`` within `GitHub Actions`_, you can use the ``wntrblm/nox`` action, which installs the latest ``nox`` and makes available all active CPython and PyPY versions provided by the GitHub Actions environment:
+
+.. code-block:: yaml
+
+    # setup nox with all active CPython and PyPY versions provided by
+    # the GitHub Actions environment i.e.
+    # python-versions: "3.7, 3.8, 3.9, 3.10, pypy-3.7, pypy-3.8, pypy-3.9"
+    - uses: wntrblm/nox
+
+    # setup nox only for a given list of python versions
+    # Limitations:
+    # - Version specifiers shall be supported by actions/setup-python
+    # - You can specify up-to 20 versions
+    # - There can only be one "major.minor" per interpreter i.e. "3.7.0, 3.7.1" is invalid
+    - uses: wntrblm/nox
+      with:
+          python-versions: "2.7, 3.5, 3.11-dev, pypy-3.9"
 
 .. _pip: https://pip.readthedocs.org
 .. _user site: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site
@@ -432,6 +448,58 @@ read more about parametrization and see more examples over at
 .. _pytest's parametrize: https://pytest.org/latest/parametrize.html#_pytest.python.Metafunc.parametrize
 
 
+Session tags
+------------
+
+You can add tags to your sessions to help you organize your development tasks:
+
+.. code-block:: python
+
+    @nox.session(tags=["style", "fix"])
+    def black(session):
+        session.install("black")
+        session.run("black", "my_package")
+
+    @nox.session(tags=["style", "fix"])
+    def isort(session):
+        session.install("isort")
+        session.run("isort", "my_package")
+
+    @nox.session(tags=["style"])
+    def flake8(session):
+        session.install("flake8")
+        session.run("flake8", "my_package")
+
+
+If you run ``nox -t style``, Nox will run all three sessions:
+
+.. code-block:: console
+
+    * black
+    * isort
+    * flake8
+
+
+If you run ``nox -t fix``, Nox will only run the ``black`` and ``isort``
+sessions:
+
+.. code-block:: console
+
+    * black
+    * isort
+    - flake8
+
+
+If you run ``nox -t style fix``, Nox will all sessions that match *any* of
+the tags, so all three sessions:
+
+.. code-block:: console
+
+    * black
+    * isort
+    * flake8
+
+
 Next steps
 ----------
 
diff --git a/docs/usage.rst b/docs/usage.rst
index c51bf05..4276fa7 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -74,12 +74,15 @@ If you have a :ref:`configured session's virtualenv <virtualenv config>`, you ca
     nox --python 3.8
     nox -p 3.7 3.8
 
-You can also use `pytest-style keywords`_ to filter test sessions:
+You can also use `pytest-style keywords`_ using ``-k`` or ``--keywords``, and
+tags using ``-t`` or ``--tags`` to filter test sessions:
 
 .. code-block:: console
 
     nox -k "not lint"
     nox -k "tests and not lint"
+    nox -k "not my_tag"
+    nox -t "my_tag" "my_other_tag"
 
 .. _pytest-style keywords: https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests
 
diff --git a/nox/_decorators.py b/nox/_decorators.py
index 19e2962..f8c1b54 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -61,6 +61,7 @@ class Func(FunctionDecorator):
         venv_backend: Any = None,
         venv_params: Any = None,
         should_warn: dict[str, Any] | None = None,
+        tags: list[str] | None = None,
     ):
         self.func = func
         self.python = python
@@ -68,6 +69,7 @@ class Func(FunctionDecorator):
         self.venv_backend = venv_backend
         self.venv_params = venv_params
         self.should_warn = should_warn or dict()
+        self.tags = tags or []
 
     def __call__(self, *args: Any, **kwargs: Any) -> Any:
         return self.func(*args, **kwargs)
@@ -81,6 +83,7 @@ class Func(FunctionDecorator):
             self.venv_backend,
             self.venv_params,
             self.should_warn,
+            self.tags,
         )
 
 
@@ -109,6 +112,7 @@ class Call(Func):
             func.venv_backend,
             func.venv_params,
             func.should_warn,
+            func.tags,
         )
         self.call_spec = call_spec
         self.session_signature = session_signature
diff --git a/nox/_options.py b/nox/_options.py
index ce4290e..4f6b3ad 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -285,6 +285,15 @@ options.add_options(
         merge_func=functools.partial(_sessions_and_keywords_merge_func, "keywords"),
         help="Only run sessions that match the given expression.",
     ),
+    _option_set.Option(
+        "tags",
+        "-t",
+        "--tags",
+        group=options.groups["sessions"],
+        noxfile=True,
+        nargs="*",
+        help="Only run sessions with the given tags.",
+    ),
     _option_set.Option(
         "posargs",
         "posargs",
diff --git a/nox/manifest.py b/nox/manifest.py
index 5cd4bc1..cbd80f6 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -172,9 +172,20 @@ class Manifest:
                 session names are checked against.
         """
         self._queue = [
-            x for x in self._queue if keyword_match(keywords, x.signatures + [x.name])
+            x
+            for x in self._queue
+            if keyword_match(keywords, x.signatures + x.tags + [x.name])
         ]
 
+    def filter_by_tags(self, tags: list[str]) -> None:
+        """Filter sessions by their tags.
+
+        Args:
+            tags (list[str]): A list of tags which session names
+                are checked against.
+        """
+        self._queue = [x for x in self._queue if set(x.tags).intersection(tags)]
+
     def make_session(
         self, name: str, func: Func, multi: bool = False
     ) -> list[SessionRunner]:
diff --git a/nox/registry.py b/nox/registry.py
index 3c54c8b..376c55e 100644
--- a/nox/registry.py
+++ b/nox/registry.py
@@ -41,6 +41,7 @@ def session_decorator(
     name: str | None = ...,
     venv_backend: Any = ...,
     venv_params: Any = ...,
+    tags: list[str] | None = ...,
 ) -> Callable[[F], F]:
     ...
 
@@ -53,6 +54,7 @@ def session_decorator(
     name: str | None = None,
     venv_backend: Any = None,
     venv_params: Any = None,
+    tags: list[str] | None = None,
 ) -> F | Callable[[F], F]:
     """Designate the decorated function as a session."""
     # If `func` is provided, then this is the decorator call with the function
@@ -71,6 +73,7 @@ def session_decorator(
             name=name,
             venv_backend=venv_backend,
             venv_params=venv_params,
+            tags=tags,
         )
 
     if py is not None and python is not None:
@@ -82,7 +85,7 @@ def session_decorator(
     if python is None:
         python = py
 
-    fn = Func(func, python, reuse_venv, name, venv_backend, venv_params)
+    fn = Func(func, python, reuse_venv, name, venv_backend, venv_params, tags=tags)
     _REGISTRY[name or func.__name__] = fn
     return fn
 
diff --git a/nox/sessions.py b/nox/sessions.py
index 4a2bf4c..d017dc8 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -657,6 +657,10 @@ class SessionRunner:
     def friendly_name(self) -> str:
         return self.signatures[0] if self.signatures else self.name
 
+    @property
+    def tags(self) -> list[str]:
+        return self.func.tags
+
     @property
     def envdir(self) -> str:
         return _normalize_path(self.global_config.envdir, self.friendly_name)
diff --git a/nox/tasks.py b/nox/tasks.py
index 177ba84..b92c7c4 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -191,6 +191,13 @@ def filter_manifest(manifest: Manifest, global_config: Namespace) -> Manifest |
             logger.error("Python version selection caused no sessions to be selected.")
             return 3
 
+    # Filter by tags.
+    if global_config.tags is not None:
+        manifest.filter_by_tags(global_config.tags)
+        if not manifest and not global_config.list_sessions:
+            logger.error("Tag selection caused no sessions to be selected.")
+            return 3
+
     # Filter by keywords.
     if global_config.keywords:
         try:
diff --git a/noxfile.py b/noxfile.py
index 44e0d77..154a100 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -19,6 +19,7 @@ import functools
 import os
 import platform
 import shutil
+import sys
 
 import nox
 
@@ -132,4 +133,28 @@ def _check_python_version(session: nox.Session) -> None:
 @nox.session(python=["3.7", "3.8", "3.9", "3.10", "pypy3.7", "pypy3.8", "pypy3.9"])
 def github_actions_default_tests(session: nox.Session) -> None:
     """Check default versions installed by the nox GHA Action"""
+    assert sys.version_info[:2] == (3, 10)
+    _check_python_version(session)
+
+
+# The following sessions are only to be run in CI to check the nox GHA action
+@nox.session(
+    python=[
+        "2.7",
+        "3.4",
+        "3.5",
+        "3.6",
+        "3.7",
+        "3.8",
+        "3.9",
+        "3.10",
+        "3.11",
+        "pypy2.7",
+        "pypy3.7",
+        "pypy3.8",
+        "pypy3.9",
+    ]
+)
+def github_actions_all_tests(session: nox.Session) -> None:
+    """Check all versions installed by the nox GHA Action"""
     _check_python_version(session)


Test patch:
diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py
new file mode 100644
index 0000000..07451cc
--- /dev/null
+++ b/tests/test_action_helper.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+GITHUB_FOLDER = Path(__file__).resolve().parent.parent / ".github"
+sys.path.insert(0, str(GITHUB_FOLDER))
+from action_helper import filter_version, setup_action  # noqa: E402
+
+VALID_VERSIONS = {
+    "2.7.18": "2.7",
+    "3.9-dev": "3.9",
+    "3.10": "3.10",
+    "3.11.0.beta1": "3.11",
+    "pypy-3.7": "3.7",
+    "pypy-3.8-v7.3.9": "3.8",
+    "pypy-3.9": "3.9",
+    "pypy3.10": "3.10",
+}
+
+
+@pytest.mark.parametrize("version", VALID_VERSIONS.keys())
+def test_filter_version(version):
+    assert filter_version(version) == VALID_VERSIONS[version]
+
+
+def test_filter_version_invalid():
+    with pytest.raises(ValueError, match=r"invalid version: 3"):
+        filter_version("3")
+
+
+def test_filter_version_invalid_major():
+    with pytest.raises(ValueError, match=r"invalid major python version: x.0"):
+        filter_version("x.0")
+
+
+def test_filter_version_invalid_minor():
+    with pytest.raises(ValueError, match=r"invalid minor python version: 3.x"):
+        filter_version("3.x")
+
+
+VALID_VERSION_LISTS = {
+    "3.7, 3.8, 3.9, 3.10, pypy-3.7, pypy-3.8, pypy-3.9": [
+        "::set-output name=interpreter_count::7",
+        "::set-output name=interpreter_0::pypy-3.7",
+        "::set-output name=interpreter_1::pypy-3.8",
+        "::set-output name=interpreter_2::pypy-3.9",
+        "::set-output name=interpreter_3::3.7",
+        "::set-output name=interpreter_4::3.8",
+        "::set-output name=interpreter_5::3.9",
+        "::set-output name=interpreter_6::3.10",
+    ],
+    "": [
+        "::set-output name=interpreter_count::1",
+        "::set-output name=interpreter_0::3.10",
+    ],
+    "3.10.4": [
+        "::set-output name=interpreter_count::1",
+        "::set-output name=interpreter_0::3.10.4",
+    ],
+    "3.9-dev,pypy3.9-nightly": [
+        "::set-output name=interpreter_count::3",
+        "::set-output name=interpreter_0::pypy3.9-nightly",
+        "::set-output name=interpreter_1::3.9-dev",
+        "::set-output name=interpreter_2::3.10",
+    ],
+    "3.10, 3.9, 3.8": [
+        "::set-output name=interpreter_count::3",
+        "::set-output name=interpreter_0::3.9",
+        "::set-output name=interpreter_1::3.8",
+        "::set-output name=interpreter_2::3.10",
+    ],
+    ",".join(f"3.{minor}" for minor in range(20)): [
+        "::set-output name=interpreter_count::20"
+    ]
+    + [
+        f"::set-output name=interpreter_{i}::3.{minor}"
+        for i, minor in enumerate(minor_ for minor_ in range(20) if minor_ != 10)
+    ]
+    + ["::set-output name=interpreter_19::3.10"],
+}
+
+
+@pytest.mark.parametrize("version_list", VALID_VERSION_LISTS.keys())
+def test_setup_action(capsys, version_list):
+    setup_action(version_list)
+    captured = capsys.readouterr()
+    lines = captured.out.splitlines()
+    assert lines == VALID_VERSION_LISTS[version_list]
+
+
+def test_setup_action_multiple_pypy():
+    with pytest.raises(
+        ValueError,
+        match=(
+            r"multiple versions specified for the same 'major.minor' PyPy interpreter"
+        ),
+    ):
+        setup_action("pypy3.9, pypy-3.9-v7.3.9")
+
+
+def test_setup_action_multiple_cpython():
+    with pytest.raises(
+        ValueError,
+        match=(
+            r"multiple versions specified for the same 'major.minor' CPython"
+            r" interpreter"
+        ),
+    ):
+        setup_action("3.10, 3.10.4")
+
+
+def test_setup_action_too_many_interpreters():
+    with pytest.raises(ValueError, match=r"too many interpreters to install: 21 > 20"):
+        setup_action(",".join(f"3.{minor}" for minor in range(21)))
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index ff84e8b..97e97cc 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -33,8 +33,13 @@ from nox.manifest import (
 
 def create_mock_sessions():
     sessions = collections.OrderedDict()
-    sessions["foo"] = mock.Mock(spec=(), python=None, venv_backend=None)
-    sessions["bar"] = mock.Mock(spec=(), python=None, venv_backend=None)
+    sessions["foo"] = mock.Mock(spec=(), python=None, venv_backend=None, tags=["baz"])
+    sessions["bar"] = mock.Mock(
+        spec=(),
+        python=None,
+        venv_backend=None,
+        tags=["baz", "qux"],
+    )
     return sessions
 
 
@@ -190,6 +195,27 @@ def test_filter_by_keyword():
     assert len(manifest) == 2
     manifest.filter_by_keywords("foo")
     assert len(manifest) == 1
+    # Match tags
+    manifest.filter_by_keywords("not baz")
+    assert len(manifest) == 0
+
+
+@pytest.mark.parametrize(
+    "tags,session_count",
+    [
+        (["baz", "qux"], 2),
+        (["baz"], 2),
+        (["qux"], 1),
+        (["missing"], 0),
+        (["baz", "missing"], 2),
+    ],
+)
+def test_filter_by_tags(tags: list[str], session_count: int):
+    sessions = create_mock_sessions()
+    manifest = Manifest(sessions, create_mock_config())
+    assert len(manifest) == 2
+    manifest.filter_by_tags(tags)
+    assert len(manifest) == session_count
 
 
 def test_list_all_sessions_with_filter():
diff --git a/tests/test_registry.py b/tests/test_registry.py
index aef0952..304b969 100644
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -60,6 +60,14 @@ def test_session_decorator_list_of_pythons(cleanup_registry):
     assert unit_tests.python == ["3.5", "3.6"]
 
 
+def test_session_decorator_tags(cleanup_registry):
+    @registry.session_decorator(tags=["tag-1", "tag-2"])
+    def unit_tests(session):
+        pass
+
+    assert unit_tests.tags == ["tag-1", "tag-2"]
+
+
 def test_session_decorator_py_alias(cleanup_registry):
     @registry.session_decorator(py=["3.5", "3.6"])
     def unit_tests(session):
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index ce8cb9b..926b52d 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -39,6 +39,7 @@ def session_func():
 session_func.python = None
 session_func.venv_backend = None
 session_func.should_warn = dict()
+session_func.tags = []
 
 
 def session_func_with_python():
@@ -243,6 +244,77 @@ def test_filter_manifest_keywords_syntax_error():
     assert return_value == 3
 
 
+@pytest.mark.parametrize(
+    "tags,session_count",
+    [
+        (None, 4),
+        (["foo"], 3),
+        (["bar"], 3),
+        (["baz"], 1),
+        (["foo", "bar"], 4),
+        (["foo", "baz"], 3),
+        (["foo", "bar", "baz"], 4),
+    ],
+)
+def test_filter_manifest_tags(tags, session_count):
+    @nox.session(tags=["foo"])
+    def qux():
+        pass
+
+    @nox.session(tags=["bar"])
+    def quux():
+        pass
+
+    @nox.session(tags=["foo", "bar"])
+    def quuz():
+        pass
+
+    @nox.session(tags=["foo", "bar", "baz"])
+    def corge():
+        pass
+
+    config = _options.options.namespace(
+        sessions=None, pythons=(), posargs=[], tags=tags
+    )
+    manifest = Manifest(
+        {
+            "qux": qux,
+            "quux": quux,
+            "quuz": quuz,
+            "corge": corge,
+        },
+        config,
+    )
+    return_value = tasks.filter_manifest(manifest, config)
+    assert return_value is manifest
+    assert len(manifest) == session_count
+
+
+@pytest.mark.parametrize(
+    "tags",
+    [
+        ["Foo"],
+        ["not-found"],
+    ],
+    ids=[
+        "tags-are-case-insensitive",
+        "tag-does-not-exist",
+    ],
+)
+def test_filter_manifest_tags_not_found(tags, caplog):
+    @nox.session(tags=["foo"])
+    def quux():
+        pass
+
+    config = _options.options.namespace(
+        sessions=None, pythons=(), posargs=[], tags=tags
+    )
+    manifest = Manifest({"quux": quux}, config)
+    return_value = tasks.filter_manifest(manifest, config)
+    assert return_value == 3
+    assert "Tag selection caused no sessions to be selected." in caplog.text
+
+
 def test_honor_list_request_noop():
     config = _options.options.namespace(list_sessions=False)
     manifest = {"thing": mock.sentinel.THING}