Repository: relekang/python-semantic-release (python)

Issue:
Support authenticating to PyPI with an API token
## Description

Support PyPI authentication using an API token.

## Use cases

- Password is not stored on CI servers
- Scope can be limited to only access the project being uploaded

## Implementation

Set `__token__` as the username and send the token as the password, as described [here](https://pypi.org/help/#apitoken).

This is already possible using the current system, but it would be nicer to be able to just set the token in an environment variable such as `PYPI_TOKEN`, and have the username filled in automatically.


Pull request description:
Support easier use of PyPI API tokens
- Allow setting the environment variable `PYPI_TOKEN`.
- Tokens are preferred over username and password if both are set.

Fixes #213

Patch:
diff --git a/action.sh b/action.sh
index 24a6747..ed1d1d5 100755
--- a/action.sh
+++ b/action.sh
@@ -2,6 +2,7 @@
 
 # Copy inputs into correctly-named environment variables
 export GH_TOKEN="${INPUT_GITHUB_TOKEN}"
+export PYPI_TOKEN="${INPUT_PYPI_TOKEN}"
 export PYPI_USERNAME="${INPUT_PYPI_USERNAME}"
 export PYPI_PASSWORD="${INPUT_PYPI_PASSWORD}"
 
diff --git a/action.yml b/action.yml
index ccb3b85..3a27e31 100644
--- a/action.yml
+++ b/action.yml
@@ -9,8 +9,10 @@ inputs:
   github_token:
     description: 'GitHub token used to push release notes and new commits/tags'
     required: true
+  pypi_token:
+    description: 'PyPI API token'
   pypi_username:
-    description: 'Username with project access to push to PyPi'
+    description: 'Username with project access to push to PyPI'
   pypi_password:
     description: 'Password to the account specified in pypi_username'
 
diff --git a/docs/automatic-releases/github-actions.rst b/docs/automatic-releases/github-actions.rst
index 8c4b68c..2734849 100644
--- a/docs/automatic-releases/github-actions.rst
+++ b/docs/automatic-releases/github-actions.rst
@@ -7,23 +7,19 @@ command.
 Inputs
 ------
 
-+-------------------+-------------------------------------------------+
-| Input             | Description                                     |
-+===================+=================================================+
-| ``github_token``  | **Required.** The GitHub token used to post     |
-|                   | release notes and push new commits/tags created |
-|                   | by Python Semantic Release.                     |
-+-------------------+-------------------------------------------------+
-| ``pypi_username`` | **Required unless upload_to_pypi is false.**    |
-|                   | Username with access to push to PyPi.           |
-+-------------------+-------------------------------------------------+
-| ``pypi_password`` | **Required unless upload_to_pypi is false.**    |
-|                   | Password to the account specified in            |
-|                   | ``pypi_username``.                              |
-+-------------------+-------------------------------------------------+
-| ``directory``     | A sub-directory to ``cd`` into before running.  |
-|                   | Defaults to the root of the repository.         |
-+-------------------+-------------------------------------------------+
++--------------------+----------------------------------------------------------------------------------------+
+| Input              | Description                                                                            |
++====================+========================================================================================+
+| ``github_token``   | See :ref:`env-gh_token`. this is usually set to ``${{ secrets.GITHUB_TOKEN }}``.       |
++--------------------+----------------------------------------------------------------------------------------+
+| ``pypi_token``     | See :ref:`env-pypi_token`.                                                             |
++--------------------+----------------------------------------------------------------------------------------+
+| ``pypi_username``  | See :ref:`env-pypi_username`.                                                          |
++--------------------+----------------------------------------------------------------------------------------+
+| ``pypi_password``  | See :ref:`env-pypi_password`.                                                          |
++--------------------+----------------------------------------------------------------------------------------+
+| ``directory``      | A sub-directory to ``cd`` into before running. Defaults to the root of the repository. |
++--------------------+----------------------------------------------------------------------------------------+
 
 Other options are taken from your regular configuration file.
 
@@ -52,12 +48,11 @@ Example Workflow
          uses: relekang/python-semantic-release@master
          with:
            github_token: ${{ secrets.GITHUB_TOKEN }}
-           pypi_username: <ADD YOUR USERNAME HERE>
-           pypi_password: ${{ secrets.PYPI_PASSWORD }}
+           pypi_token: ${{ secrets.PYPI_TOKEN }}
 
-``PYPI_PASSWORD`` should be set as a secret on your repository's settings page.
-It is safe to include your username directly in the configuration, although you
-could also set it as a secret if you wish.
+``PYPI_TOKEN`` should be set as a secret on your repository's settings page.
+It is also possible to use username and password authentication in a similar
+fashion.
 
 .. warning::
   You must set `fetch-depth` to 0 when using ``actions/checkout@v2``, since
@@ -88,16 +83,14 @@ multiple projects.
      with:
        directory: ./project1
        github_token: ${{ secrets.GITHUB_TOKEN }}
-       pypi_username: <ADD YOUR USERNAME HERE>
-       pypi_password: ${{ secrets.PYPI_PASSWORD }}
+       pypi_token: ${{ secrets.PYPI_TOKEN }}
 
    - name: Release Project 2
      uses: relekang/python-semantic-release@master
      with:
        directory: ./project2
        github_token: ${{ secrets.GITHUB_TOKEN }}
-       pypi_username: <ADD YOUR USERNAME HERE>
-       pypi_password: ${{ secrets.PYPI_PASSWORD }}
+       pypi_token: ${{ secrets.PYPI_TOKEN }}
 
 .. note::
   The release notes posted to GitHub will not currently distinguish which
diff --git a/docs/automatic-releases/index.rst b/docs/automatic-releases/index.rst
index 7441be7..4409d75 100644
--- a/docs/automatic-releases/index.rst
+++ b/docs/automatic-releases/index.rst
@@ -64,11 +64,10 @@ upload to pypi and push to git and it should be ready to roll.
 
 Configuring pypi upload
 ^^^^^^^^^^^^^^^^^^^^^^^
-In order to upload to pypi python-semantic-release needs credentials to an account that
-have access to the given package. Either by being logged in through a pip configuration file
-or through environment variables. The latter is most often preferable in an CI environment.
-You will need to set ``PYPI_USERNAME`` and ``PYPI_PASSWORD``. Make sure that you mark it
-as a secret on your CI service so that your password will be left out of the build logs.
+In order to upload to PYPI, Python Semantic Release needs credentials to access
+the project. You will need to set the environment variable :ref:`env-pypi_token`.
+Make sure that you mark it as a secret on your CI service so that it is left out
+of the build logs.
 
 
 .. _automatic-github:
diff --git a/docs/automatic-releases/travis.rst b/docs/automatic-releases/travis.rst
index 068a481..1a15660 100644
--- a/docs/automatic-releases/travis.rst
+++ b/docs/automatic-releases/travis.rst
@@ -16,10 +16,10 @@ You will need to set up three environment variables in Travis. An easy way to do
 is to go to the settings page for your package and add them there. Make sure that the
 secret toggle is set correct for the ones that are secret.
 
-You will need to set ``PYPI_USERNAME`` and ``PYPI_PASSWORD`` with values corresponding
-to a pypi user with access to the given package. Furthermore, you need to set ``GH_TOKEN``
-with a personal access token for Github. It will need either ``repo`` or ``public_repo`` scope
-depending on whether the repository is private or public.
+You will need to set :ref:`env-pypi_token` to a PyPI API token. Furthermore,
+you need to set ``GH_TOKEN`` with a personal access token for Github. It will
+need either ``repo`` or ``public_repo`` scope depending on whether the
+repository is private or public.
 
 More information on how to set environment variables can be found on
 `Travis documentation on environment variables`_.
diff --git a/docs/configuration.rst b/docs/configuration.rst
index dc0c0c1..3d56125 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -122,6 +122,7 @@ Distributions
 ``upload_to_pypi``
 ------------------
 If set to false the pypi uploading will be disabled.
+See :ref:`env-pypi_token` which must also be set for this to work.
 
 ``upload_to_release``
 ---------------------
diff --git a/docs/envvars.rst b/docs/envvars.rst
index e72ba1f..6780696 100644
--- a/docs/envvars.rst
+++ b/docs/envvars.rst
@@ -76,17 +76,35 @@ and click on *Personal access token*.
 A personal access token from GitLab. This is used for authenticating
 when pushing tags, publishing releases etc.
 
+.. _env-pypi_token:
+
+``PYPI_TOKEN``
+--------------
+Set an API token for publishing to https://pypi.org/. Information on how to
+obtain a token is given `here <https://pypi.org/help/#apitoken>`_.
+
+See :ref:`automatic-pypi` for more about PyPI uploads.
+
 .. _env-pypi_password:
 
 ``PYPI_PASSWORD``
 -----------------
 Used together with :ref:`env-pypi_username` when publishing to https://pypi.org/.
 
+.. warning::
+  You should use :ref:`env-pypi_token` instead of username and password
+  authentication for the following reasons:
+
+  - It is `strongly recommended by PyPI <https://pypi.org/help/#apitoken>`_.
+  - Tokens can be given access to only a single project, which reduces the
+    possible damage if it is compromised.
+  - You can change your password without having to update it in CI settings.
+  - If your PyPI username is the same as your GitHub and you have it set
+    as a secret in a CI service, they will likely scrub it from the build
+    output. This can break things, for example repository links.
+
 .. _env-pypi_username:
 
 ``PYPI_USERNAME``
 -----------------
 Used together with :ref:`env-pypi_password` when publishing to https://pypi.org/.
-
-.. note::
-  See :ref:`automatic-pypi` for more about PyPI uploads.
diff --git a/semantic_release/cli.py b/semantic_release/cli.py
index 5fd0b9c..3f76753 100644
--- a/semantic_release/cli.py
+++ b/semantic_release/cli.py
@@ -235,8 +235,6 @@ def publish(**kwargs):
             logger.info("Uploading to PyPI")
             upload_to_pypi(
                 path=dist_path,
-                username=os.environ.get("PYPI_USERNAME"),
-                password=os.environ.get("PYPI_PASSWORD"),
                 # If we are retrying, we don't want errors for files that are already on PyPI.
                 skip_existing=retry,
             )
diff --git a/semantic_release/history/__init__.py b/semantic_release/history/__init__.py
index 2b3073e..f9ddc9d 100644
--- a/semantic_release/history/__init__.py
+++ b/semantic_release/history/__init__.py
@@ -48,17 +48,12 @@ def get_current_version_by_config_file() -> str:
 
     with open(filename, "r") as fd:
         file_text = fd.read()
-        # checks for variable in the format variable=version
+        # Check for variable in the format variable=version or variable:version
         parts = re.search(
-            r'^{0}\s*=\s*[\'"]([^\'"]*)[\'"]'.format(variable), file_text, re.MULTILINE
+            r'^{0}\s*[=:]\s*[\'"]([^\'"]*)[\'"]'.format(variable),
+            file_text,
+            re.MULTILINE,
         )
-        # checks for variable in the format variable:version
-        if not parts:
-            parts = re.search(
-                r'{0}\s*:\s*[\'"]([^\'"]*)[\'"]'.format(variable),
-                file_text,
-                re.MULTILINE,
-            )
         if not parts:
             raise ImproperConfigurationError
 
diff --git a/semantic_release/pypi.py b/semantic_release/pypi.py
index 1710ef0..bc7b9eb 100644
--- a/semantic_release/pypi.py
+++ b/semantic_release/pypi.py
@@ -1,6 +1,7 @@
 """Helper for using Twine to upload to PyPI.
 """
 import logging
+import os
 
 from invoke import run
 
@@ -14,22 +15,32 @@ logger = logging.getLogger(__name__)
 @LoggedFunction(logger)
 def upload_to_pypi(
     path: str = "dist",
-    username: str = None,
-    password: str = None,
     skip_existing: bool = False,
 ):
     """Upload wheels to PyPI with Twine.
 
     Wheels must already be created and stored at the given path.
 
+    Credentials are taken from either the environment variable
+    ``PYPI_TOKEN``, or from ``PYPI_USERNAME`` and ``PYPI_PASSWORD``.
+
     :param path: Path to dist folder containing the files to upload.
-    :param username: PyPI account username.
-    :param password: PyPI account password.
     :param skip_existing: Continue uploading files if one already exists.
         (Only valid when uploading to PyPI. Other implementations may not support this.)
     """
-    if username is None or password is None or username == "" or password == "":
-        raise ImproperConfigurationError("Missing credentials for uploading")
+    # Attempt to get an API token from environment
+    token = os.environ.get("PYPI_TOKEN")
+    if not token:
+        # Look for a username and password instead
+        username = os.environ.get("PYPI_USERNAME")
+        password = os.environ.get("PYPI_PASSWORD")
+        if not (username or password):
+            raise ImproperConfigurationError("Missing credentials for uploading to PyPI")
+    elif not token.startswith("pypi-"):
+        raise ImproperConfigurationError("PyPI token should begin with \"pypi-\"")
+    else:
+        username = '__token__'
+        password = token
 
     run(
         "twine upload -u '{}' -p '{}' {} \"{}/*\"".format(


Test patch:
diff --git a/tests/test_pypi.py b/tests/test_pypi.py
index ffb4f27..4cd1b2d 100644
--- a/tests/test_pypi.py
+++ b/tests/test_pypi.py
@@ -8,21 +8,46 @@ from . import mock
 
 class PypiTests(TestCase):
     @mock.patch("semantic_release.pypi.run")
-    def test_upload_without_arguments(self, mock_run):
-        upload_to_pypi(username="username", password="password")
+    @mock.patch.dict("os.environ", {"PYPI_USERNAME": "username", "PYPI_PASSWORD": "password"})
+    def test_upload_with_password(self, mock_run):
+        upload_to_pypi()
         self.assertEqual(
             mock_run.call_args_list,
             [mock.call("twine upload -u 'username' -p 'password'  \"dist/*\"")],
         )
 
     @mock.patch("semantic_release.pypi.run")
+    @mock.patch.dict("os.environ", {"PYPI_TOKEN": "pypi-x"})
+    def test_upload_with_token(self, mock_run):
+        upload_to_pypi()
+        self.assertEqual(
+            mock_run.call_args_list,
+            [mock.call("twine upload -u '__token__' -p 'pypi-x'  \"dist/*\"")],
+        )
+
+    @mock.patch("semantic_release.pypi.run")
+    @mock.patch.dict("os.environ", {"PYPI_TOKEN": "pypi-x", "PYPI_USERNAME": "username", "PYPI_PASSWORD": "password"})
+    def test_upload_prefers_token_over_password(self, mock_run):
+        upload_to_pypi()
+        self.assertEqual(
+            mock_run.call_args_list,
+            [mock.call("twine upload -u '__token__' -p 'pypi-x'  \"dist/*\"")],
+        )
+
+    @mock.patch("semantic_release.pypi.run")
+    @mock.patch.dict("os.environ", {"PYPI_TOKEN": "pypi-x"})
     def test_upload_with_custom_path(self, mock_run):
-        upload_to_pypi(path="custom-dist", username="username", password="password")
+        upload_to_pypi(path="custom-dist")
         self.assertEqual(
             mock_run.call_args_list,
-            [mock.call("twine upload -u 'username' -p 'password'  \"custom-dist/*\"")],
+            [mock.call("twine upload -u '__token__' -p 'pypi-x'  \"custom-dist/*\"")],
         )
 
+    @mock.patch.dict("os.environ", {"PYPI_TOKEN": "invalid"})
+    def test_raises_error_when_token_invalid(self):
+        with self.assertRaises(ImproperConfigurationError):
+            upload_to_pypi()
+
     def test_raises_error_when_missing_credentials(self):
         with self.assertRaises(ImproperConfigurationError):
             upload_to_pypi()