From ff8b9856a0bb045932f4810410404261cd848ea4 Mon Sep 17 00:00:00 2001
From: Pierre Smeyers <pierre.smeyers@gmail.com>
Date: Mon, 21 Feb 2022 14:12:57 +0100
Subject: [PATCH] feat(release): complete release process refactoring

BREAKING CHANGE: complete refactoring or release process, including variables and jobs redefinition
- no more separate publish job: the entire release process is now performed by the py-release job
- TWINE_XXX variables removed and replaced by PYTHON_REPOSITORY_XXX
- RELEASE_VERSION_PART variable replaced by PYTHON_RELEASE_NEXT

For additional info, see the doc.
---
 README.md                      | 112 +++++++++-----
 kicker.json                    |  80 +++++-----
 templates/gitlab-ci-python.yml | 265 +++++++++++++++++++++------------
 3 files changed, 282 insertions(+), 175 deletions(-)

diff --git a/README.md b/README.md
index 9bc4d5e..7d06b4c 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,12 @@ and/or `setup.py` and/or `requirements.txt`), but the build system might also be
 
 ## Jobs
 
+### `py-package` job
+
+This job allows building your Python project [distribution packages](https://packaging.python.org/en/latest/glossary/#term-Distribution-Package).
+
+It is bound to the `build` stage, it is **disabled by default** and can be enabled by setting `$PYTHON_PACKAGE_ENABLED` to `true`.
+
 ### Lint jobs
 
 #### `py-pylint` job
@@ -206,7 +212,7 @@ It is bound to the `test` stage, and uses the following variables:
 
 | Name             | description                                                            | default value     |
 | ---------------- | ---------------------------------------------------------------------- | ----------------- |
-| `BANDIT_ENABLED` | Set to `true` to enable Bandit analysis                                     | _none_ (disabled) |
+| `BANDIT_ENABLED` | Set to `true` to enable Bandit analysis                                | _none_ (disabled) |
 | `BANDIT_ARGS`    | Additional [Bandit CLI options](https://github.com/PyCQA/bandit#usage) | `--recursive .`   |
 
 This job outputs a **textual report** in the console, and in case of failure also exports a JSON report in the `reports/`
@@ -220,7 +226,7 @@ It is bound to the `test` stage, and uses the following variables:
 
 | Name             | description                                                             | default value     |
 | ---------------- | ----------------------------------------------------------------------- | ----------------- |
-| `SAFETY_ENABLED` | Set to `true` to enable Safety job                                           | _none_ (disabled) |
+| `SAFETY_ENABLED` | Set to `true` to enable Safety job                                      | _none_ (disabled) |
 | `SAFETY_ARGS`    | Additional [Safety CLI options](https://github.com/pyupio/safety#usage) | `--full-report`   |
 
 This job outputs a **textual report** in the console, and in case of failure also exports a JSON report in the `reports/`
@@ -234,62 +240,92 @@ It is bound to the `test` stage, and uses the following variables:
 
 | Name             | description                                                             | default value     |
 | ---------------- | ----------------------------------------------------------------------- | ----------------- |
-| `PYTHON_TRIVY_ENABLED` | Set to `true` to enable Trivy job                                           | _none_ (disabled) |
+| `PYTHON_TRIVY_ENABLED` | Set to `true` to enable Trivy job                                 | _none_ (disabled) |
 | `PYTHON_TRIVY_ARGS`    | Additional [Trivy CLI options](https://aquasecurity.github.io/trivy/v0.21.1/getting-started/cli/fs/) | `--vuln-type library`   |
 
 This job outputs a **textual report** in the console, and in case of failure also exports a JSON report in the `reports/`
 directory _(relative to project root dir)_.
 
-### Package jobs
+### `py-release` job
+
+This job is **disabled by default** and allows to perform a complete release of your Python code:
 
-#### `py-package` job
+1. increase the Python project version,
+2. Git commit changes and create a Git tag with the new version number,
+3. build the [Python packages](https://packaging.python.org/),
+4. publish the built packages to a PyPI compatible repository ([GitLab packages](https://docs.gitlab.com/ee/user/packages/pypi_repository/) by default).
 
-This job is **disabled by default** and performs a packaging of your Python code.
+The Python template supports two packaging systems:
 
-It is bound to the `package-build` stage, applies only on git tags and uses the following variables:
+* [Poetry](https://python-poetry.org/): uses Poetry-specific [version](https://python-poetry.org/docs/cli/#version), [build](https://python-poetry.org/docs/cli/#build) and [publish](https://python-poetry.org/docs/cli/#publish) commands.
+* [Setuptools](https://setuptools.pypa.io/): uses [Bumpversion](https://github.com/peritus/bumpversion) as version management, [build](https://pypa-build.readthedocs.io/) as package builder and [Twine](https://twine.readthedocs.io/) to publish.
 
-| Name            | description                                          | default value |
-| --------------- | ---------------------------------------------------- | ------------- |
-| `PYTHON_FORCE_PACKAGE` | Set to `true` to force the packaging even if not on tag related event | _none_ (disabled) |
+The release job is bound to the `publish` stage, appears only on production and integration branches and uses the following variables:
 
-### Publish jobs
+| Name                    | description                                                             | default value     |
+| ----------------------- | ----------------------------------------------------------------------- | ----------------- |
+| `PYTHON_RELEASE_ENABLED`| Set to `true` to enable the release job                                 | _none_ (disabled) |
+| `PYTHON_RELEASE_NEXT`   | The part of the version to increase (one of: `major`, `minor`, `patch`) | `minor`           |
+| `PYTHON_SEMREL_RELEASE_DISABLED`| Set to `true` to disable [semantic-release integration](#semantic-release-integration)   | _none_ (disabled) |
+| `GIT_USERNAME`          | Git username for Git push operations (see below)                        | _none_            |
+| :lock: `GIT_PASSWORD`   | Git password for Git push operations (see below)                        | _none_            |
+| :lock: `GIT_PRIVATE_KEY`| SSH key for Git push operations (see below)                             | _none_            |
+| `PYTHON_REPOSITORY_URL`| Target PyPI repository to publish packages                              | _[GitLab project's PyPI packages repository](https://docs.gitlab.com/ee/user/packages/pypi_repository/)_ |
+| `PYTHON_REPOSITORY_USERNAME`| Target PyPI repository username credential                              | `gitlab-ci-token` |
+| :lock: `PYTHON_REPOSITORY_PASSWORD`| Target PyPI repository password credential                              | `$CI_JOB_TOKEN` |
 
-#### `py-release` job
+#### Setuptools tip
 
-This job is **disabled by default** and performs an automatic tagging of your Python code.
+If you're using a `setup.cfg` declarative file for your project Setuptools configuration, then you will have to write a
+`.bumpversion.cfg` file to workaround a bug that prevents Bumpversion from updating the project version in your `setup.cfg` file.
 
-* [Bumpversion](https://github.com/peritus/bumpversion) Python library is used for version management.
-* Looks for an existing `.bumpversion.cfg` at the project root. If found, it will be the configuration used by bumpversion. If not, the `$RELEASE_VERSION_PART` variable and `setup.py` will be used instead.
-* Creating a Git tag involves an authenticated and authorized Git user.
+Example of `.bumpversion.cfg` file:
 
-**Don't use your personal password !!!
-Use an [access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) with write_repository rights.
-If you have a generic account, add it to the project and generate access token from this account.**
+```ini
+[bumpversion]
+# same version as in your setup.cfg
+current_version = 0.5.0
 
-It is bound to the `publish` stage, applies only on master branch and uses the following variables:
+[bumpversion:file:setup.cfg]
+# any additional config here
+# see: https://github.com/peritus/bumpversion#file-specific-configuration
+```
 
-| Name                   | description                                                             | default value     |
-| ---------------------- | ----------------------------------------------------------------------- | ----------------- |
-| `RELEASE_VERSION_PART` | The part of the version to increase (one of: `major`, `minor`, `patch`) | `minor`           |
-| `RELEASE_USERNAME`     | Username credential for git push                                        | _none_ (disabled) |
-| `RELEASE_ACCESS_TOKEN` | Password credential for git push                                        | _none_            |
+#### `semantic-release` integration
 
-#### `py-publish` job
+If you activate the [`semantic-release-info` job from the `semantic-release` template](https://gitlab.com/to-be-continuous/semantic-release/#semantic-release-info-job), the `py-release` job will rely on the generated next version info.
+Thus, a release will be performed only if a next semantic release is present.
 
-This job is **disabled by default** and performs a publication of your Python code.
+You should disable the `semantic-release` job (as it's the `py-release` job that will perform the release and so we only need the `semantic-release-info` job) by setting `SEMREL_RELEASE_DISABLED` to `true`.
 
-It is bound to the `publish` stage, applies only on git tags and uses the following variables:
+Finally, the semantic-release integration can be disabled with the `PYTHON_SEMREL_RELEASE_DISABLED` variable.
 
-| Name                   | description                                              | default value     |
-| ---------------------- | -------------------------------------------------------- | ----------------- |
-| `PYTHON_PUBLISH_ENABLED`| Set to `true` to enable the publish job                 | _none_ (disabled) |
-| `TWINE_REPOSITORY_URL` | Where to publish your Python project                     | GitLab Project's Pypi Packages registry |
-| `TWINE_USERNAME`       | Username credential to publish to \$TWINE_REPOSITORY_URL | `gitlab-ci-token` |
-| `TWINE_PASSWORD`       | Password credential to publish to \$TWINE_REPOSITORY_URL | `$CI_JOB_TOKEN` |
+#### 
 
-More info:
+#### Git authentication
+
+A Python release involves some Git push operations.
+
+You can either use a SSH key or user/password credentials.
+
+##### Using a SSH key
+
+We recommend you to use a [project deploy key](https://docs.gitlab.com/ee/user/project/deploy_keys/#project-deploy-keys) with write access to your project.
+
+The key should not have a passphrase (see [how to generate a new SSH key pair](https://docs.gitlab.com/ce/ssh/README.html#generating-a-new-ssh-key-pair)).
+
+Specify :lock: `$GIT_PRIVATE_KEY` as secret project variable with the private part of the deploy key.
+
+```PEM
+-----BEGIN OPENSSH PRIVATE KEY-----
+blablabla
+-----END OPENSSH PRIVATE KEY-----
+```
+
+The template handles both classic variable and file variable.
+
+##### Using user/password credentials
 
-* [Python Packaging User Guide](https://packaging.python.org/)
-* [PyPI packages in the Package Registry](https://docs.gitlab.com/ee/user/packages/pypi_repository/)
+Simply specify :lock: `$GIT_USERNAME` and :lock: `$GIT_PASSWORD` as secret project variables.
 
-If you want to automatically create tag and publish your Python package, please have a look [here](#release-python)
+Note that the password should be an access token (preferably a [project](https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html) or [group](https://docs.gitlab.com/ee/user/group/settings/group_access_tokens.html) access token) with `read_repository` and `write_repository` scopes.
diff --git a/kicker.json b/kicker.json
index 9c5a058..da0fc1e 100644
--- a/kicker.json
+++ b/kicker.json
@@ -150,51 +150,14 @@
         }
       ]
     },
-    {
-      "id": "package",
-      "name": "package",
-      "description": "Packaging of your Python code",
-      "variables": [
-        {
-          "name": "PYTHON_FORCE_PACKAGE",
-          "description": "Force the packaging even if not on tag related event",
-          "type": "boolean"
-        }
-      ]
-    },
-    {
-      "id": "publish",
-      "name": "Publish",
-      "description": "Publish your code to a [Twine](https://pypi.org/project/twine/) repository",
-      "enable_with": "PYTHON_PUBLISH_ENABLED",
-      "variables": [
-        {
-          "name": "TWINE_REPOSITORY_URL",
-          "type": "url",
-          "description": "Twine repository url to publish you python project",
-          "default": "${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/packages/pypi"
-        },
-        {
-          "name": "TWINE_USERNAME",
-          "description": "Twine repository username credential",
-          "secret": true,
-          "default": "gitlab-ci-token"
-        },
-        {
-          "name": "TWINE_PASSWORD",
-          "description": "Twine repository password credential",
-          "secret": true,
-          "default": "$CI_JOB_TOKEN"
-        }
-      ]
-    },
     {
       "id": "release",
       "name": "Release",
       "description": "Manually trigger a release of your code (uses [bumpversion](https://pypi.org/project/bumpversion/))",
+      "enable_with": "PYTHON_RELEASE_ENABLED",
       "variables": [
         {
-          "name": "RELEASE_VERSION_PART",
+          "name": "PYTHON_RELEASE_NEXT",
           "type": "enum",
           "values": [
             "",
@@ -207,16 +170,43 @@
           "advanced": true
         },
         {
-          "name": "RELEASE_USERNAME",
-          "description": "Username credential for Git push",
+          "name": "PYTHON_SEMREL_RELEASE_DISABLED",
+          "description": "Disable semantic-release integration",
+          "type": "boolean",
+          "advanced": true
+        },
+        {
+          "name": "GIT_USERNAME",
+          "description": "Git username for Git push operations",
+          "secret": true
+        },
+        {
+          "name": "GIT_PASSWORD",
+          "description": "Git password for Git push operations",
+          "secret": true
+        },
+        {
+          "name": "GIT_PRIVATE_KEY",
+          "description": "SSH key for Git push operations",
+          "secret": true
+        },
+        {
+          "name": "PYTHON_REPOSITORY_URL",
+          "type": "url",
+          "description": "Target PyPI repository to publish packages.\n\n_defaults to [GitLab project's packages repository](https://docs.gitlab.com/ee/user/packages/pypi_repository/)_",
+          "default": "${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/packages/pypi"
+        },
+        {
+          "name": "PYTHON_REPOSITORY_USERNAME",
+          "description": "Target PyPI repository username credential",
           "secret": true,
-          "mandatory": true
+          "default": "gitlab-ci-token"
         },
         {
-          "name": "RELEASE_ACCESS_TOKEN",
-          "description": "Password credential for Git push",
+          "name": "PYTHON_REPOSITORY_PASSWORD",
+          "description": "Target PyPI repository password credential",
           "secret": true,
-          "mandatory": true
+          "default": "$CI_JOB_TOKEN"
         }
       ]
     }
diff --git a/templates/gitlab-ci-python.yml b/templates/gitlab-ci-python.yml
index 75c9a90..a8188eb 100644
--- a/templates/gitlab-ci-python.yml
+++ b/templates/gitlab-ci-python.yml
@@ -46,13 +46,13 @@ variables:
   PYTHON_TRIVY_IMAGE: aquasec/trivy:latest
   PYTHON_TRIVY_ARGS: "--vuln-type library"
 
-  RELEASE_VERSION_PART: "minor"
+  PYTHON_RELEASE_NEXT: "minor"
 
   # By default, publish on the Packages registry of the project
   # https://docs.gitlab.com/ee/user/packages/pypi_repository/#authenticate-with-a-ci-job-token
-  TWINE_REPOSITORY_URL: ${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/packages/pypi
-  TWINE_USERNAME: 'gitlab-ci-token'
-  TWINE_PASSWORD: $CI_JOB_TOKEN
+  PYTHON_REPOSITORY_URL: ${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/packages/pypi
+  PYTHON_REPOSITORY_USERNAME: 'gitlab-ci-token'
+  PYTHON_REPOSITORY_PASSWORD: $CI_JOB_TOKEN
 
 
 .python-scripts: &python-scripts |
@@ -360,65 +360,165 @@ variables:
       if ! command -v poetry > /dev/null; then pip install ${PIP_OPTS} poetry; fi
       poetry build
       ;;
-    setuptools)
+    *)
       # shellcheck disable=SC2086
-      pip install ${PIP_OPTS} setuptools build
+      pip install ${PIP_OPTS} build
       python -m build
       ;;
-    *)
-      log_error "--- packaging is unsupported with $PYTHON_BUILD_SYSTEM build system: read template doc"
-      exit 1
-      ;;
     esac
   }
 
-  function _publish() {
-    case "$PYTHON_BUILD_SYSTEM" in
-    poetry)
-      # shellcheck disable=SC2086
-      if ! command -v poetry > /dev/null; then pip install ${PIP_OPTS} poetry; fi
-      poetry config repositories.user_defined  "$TWINE_REPOSITORY_URL"
-      poetry publish --username "$TWINE_USERNAME" --password "$TWINE_PASSWORD" --repository user_defined
-      ;;
-    setuptools)
-      # shellcheck disable=SC2086
-      pip install ${PIP_OPTS} twine
-      twine upload --verbose dist/*.tar.gz
-      twine upload --verbose dist/*.whl
-      ;;
-    *)
-      log_error "--- publish is unsupported with $PYTHON_BUILD_SYSTEM build system: read template doc"
+  function configure_scm_auth() {
+    git_base_url=$(echo "$CI_REPOSITORY_URL" | cut -d\@ -f2)
+    if [[ -n "${GIT_USERNAME}" ]] && [[ -n "${GIT_PASSWORD}" ]]; then
+      log_info "--- using https protocol with SCM credentials from env (\$GIT_USERNAME and \$GIT_PASSWORD)..."
+      export git_auth_url="https://${GIT_USERNAME}:${GIT_PASSWORD}@${git_base_url}"
+    elif [[ -n "${GIT_PRIVATE_KEY}" ]]; then
+      log_info "--- using ssh protocol with SSH key from env (\$GIT_PRIVATE_KEY)..."
+      mkdir -m 700 "${HOME}/.ssh"
+      ssh-keyscan -H "${CI_SERVER_HOST}" >> ~/.ssh/known_hosts
+      eval "$(ssh-agent -s)"
+      # Handle file variable
+      if [[ -f "${GIT_PRIVATE_KEY}" ]]; then
+        tr -d '\r' < "${GIT_PRIVATE_KEY}" | ssh-add -
+      else
+        echo "${GIT_PRIVATE_KEY}" | tr -d '\r' | ssh-add -
+      fi
+      export git_auth_url="git@${git_base_url/\//:}"
+    else
+      log_error "--- Please specify either \$GIT_USERNAME and \$GIT_PASSWORD or \$GIT_PRIVATE_KEY variables to enable release (see doc)."
       exit 1
-      ;;
-    esac
+    fi
   }
 
   function _release() {
-    if [[ "${PYTHON_BUILD_SYSTEM}" == "poetry" ]]
+    # 0: guess packaging system
+    if [[ -f "pyproject.toml" ]]
+    then
+      # that might be PEP 517 if a build-backend is specified
+      # otherwise it might be only used as configuration file for development tools...
+      build_backend=$(sed -rn 's/^build-backend *= *"([^"]*)".*/\1/p' pyproject.toml)
+      if [[ "$build_backend" ]]
+      then
+        case "$build_backend" in
+        poetry.core.masonry.api)
+          log_info "--- Packaging system auto-detected: Poetry"
+          pkg_system="poetry"
+          ;;
+        setuptools.build_meta)
+          log_info "--- Packaging system auto-detected: Setuptools (PEP 517)"
+          pkg_system="setuptools"
+          ;;
+        *)
+          log_error "--- Unsupported PEP 517 backend \\e[33;1m${build_backend}\\e[0m: abort"
+          exit 1
+          ;;
+        esac
+      fi
+    fi
+
+    if [[ -z "$pkg_system" ]]
+    then
+      if [[ -f "setup.py" ]]
+      then
+        log_info "--- Packaging system auto-detected: Setuptools (legacy)"
+        pkg_system="setuptools"
+      else
+        log_error "--- Couldn't find any supported packaging system: abort"
+        exit 1
+      fi
+    fi
+
+    # 1: retrieve next release info from semantic-release
+    if [ "$SEMREL_INFO_ON" ] && [ "$PYTHON_SEMREL_RELEASE_DISABLED" != "true" ]
+    then
+      if [ -z "$SEMREL_INFO_NEXT_VERSION" ]
+      then
+        log_info "[semantic-release] no new version to release: skip"
+        exit 0
+      else
+        py_cur_version="$SEMREL_INFO_LAST_VERSION"
+        py_next_version="$SEMREL_INFO_NEXT_VERSION"
+        py_release_part="$SEMREL_INFO_NEXT_VERSION_TYPE"
+        log_info "[semantic-release] new ($py_release_part) release required \\e[1;94m${py_cur_version}\\e[0m → \\e[1;94m${py_next_version}\\e[0m"
+      fi
+    fi
+
+    # 2: bumpversion (+ Git commit & tag)
+    if [[ "$pkg_system" == "poetry" ]]
     then
       # shellcheck disable=SC2086
       if ! command -v poetry > /dev/null; then pip install ${PIP_OPTS} poetry; fi
-      poetry version "${RELEASE_VERSION_PART}"
+      if [[ -z "$py_next_version" ]]
+      then
+        py_cur_version=$(poetry version --short)
+        py_next_version="$PYTHON_RELEASE_NEXT"
+      fi
+      log_info "[Poetry] change version \\e[1;94m${py_cur_version}\\e[0m → \\e[1;94m${py_next_version}\\e[0m"
+      poetry version ${TRACE+--verbose} "$py_next_version"
+      # eval exact next version
+      py_next_version=$(poetry version --short)
+      git add pyproject.toml
+      git commit -m "chore(python-release): $py_cur_version → $py_next_version [ci skip]"
+      git tag "$py_next_version"
     else
+      # Setuptools / bumpversion
       # shellcheck disable=SC2086
       pip install ${PIP_OPTS} bumpversion
-
-      if [[ -f ".bumpversion.cfg" ]]; then
-        log_info "--- .bumpversion.cfg file found "
-        export bumpversion_args="${RELEASE_VERSION_PART} --verbose"
+      py_commit_message="chore(python-release): {current_version} → {new_version} [ci skip]"
+      if [[ "$py_next_version" ]]
+      then
+        # explicit release version (semantic-release)
+        log_info "[Setuptools] bumpversion \\e[1;94m${py_cur_version}\\e[0m → \\e[1;94m${py_next_version}\\e[0m"
+        # create cfg in case it doesn't exist - will be updated by bumpversion
+        touch .bumpversion.cfg
+        bumpversion ${TRACE+--verbose} --current-version "$py_cur_version" --commit --message "$py_commit_message" --tag --tag-name "{new_version}" "$py_release_part"
+      elif [[ -f "setup.py" ]]
+      then
+        # retrieve current version from setup.py
+        py_cur_version=$(python setup.py --version)
+        py_release_part="$PYTHON_RELEASE_NEXT"
+        log_info "[Setuptools] bumpversion ($py_release_part) from \\e[1;94m${py_cur_version}\\e[0m"
+        bumpversion ${TRACE+--verbose} --current-version "$py_cur_version" --commit --message "$py_commit_message" --tag --tag-name "{new_version}" "$py_release_part"
+      elif [[ -f ".bumpversion.cfg" ]]
+      then
+        # current version shall be set in .bumpversion.cfg
+        py_release_part="$PYTHON_RELEASE_NEXT"
+        log_info "[bumpversion] increase \\e[1;94m${py_release_part}\\e[0m"
+        bumpversion ${TRACE+--verbose} --commit --message "$py_commit_message" --tag --tag-name "{new_version}" "$py_release_part"
       else
-        log_info "---  No .bumpversion.cfg file found "
-        if [[ -f "setup.py" ]]; then
-          log_info "---  Getting current version of setup.py file "
-          current_version=$(python setup.py --version)
-          export bumpversion_args=" --verbose --current-version ${current_version} --tag --tag-name {new_version} --commit ${RELEASE_VERSION_PART} setup.py"
-        else
-          log_warn "---  No setup.py file found. Cannot perform release."
-        fi
+        log_error "--- setup.py or .bumpversion.cfg file required to retrieve current version: cannot perform release"
+        exit 1
       fi
-      log_info "--- Release args: ${bumpversion_args}"
+    fi
+
+    # 3: Git commit, tag and push
+    log_info "--- git push commit and tag..."
+    git push "$git_auth_url" "$CI_BUILD_REF_NAME"
+    git push "$git_auth_url" --tags
+
+    # 4: build new version distribution
+    log_info "--- build distribution packages..."
+    if [[ "$pkg_system" == "poetry" ]]
+    then
+      poetry build ${TRACE+--verbose}
+    else
+      # shellcheck disable=SC2086
+      pip install ${PIP_OPTS} build
+      rm -rf dist
+      python -m build
+    fi
 
-      bumpversion "${bumpversion_args}"
+    # 5: publish packages
+    log_info "--- publish distribution packages..."
+    if [[ "$pkg_system" == "poetry" ]]
+    then
+      poetry config repositories.user_defined  "$PYTHON_REPOSITORY_URL"
+      poetry publish ${TRACE+--verbose} --username "$PYTHON_REPOSITORY_USERNAME" --password "$PYTHON_REPOSITORY_PASSWORD" --repository user_defined
+    else
+      # shellcheck disable=SC2086
+      pip install ${PIP_OPTS} twine
+      twine upload ${TRACE+--verbose} --username "$PYTHON_REPOSITORY_USERNAME" --password "$PYTHON_REPOSITORY_PASSWORD" --repository-url "$PYTHON_REPOSITORY_URL" dist/*
     fi
   }
 
@@ -473,12 +573,26 @@ variables:
 stages:
   - build
   - test
-  - package-build
   - publish
 
 ###############################################################################################
 #                                      build stage                                             #
 ###############################################################################################
+# build Python packages as artifacts
+py-package:
+  extends: .python-base
+  stage: build
+  script:
+    - _package
+  artifacts:
+    paths:
+      - $PYTHON_PROJECT_DIR/dist/*
+  rules:
+    # exclude merge requests
+    - if: $CI_MERGE_REQUEST_ID
+      when: never
+    - if: '$PYTHON_PACKAGE_ENABLED == "true"'
+
 py-lint:
   extends: .python-base
   stage: build
@@ -713,7 +827,6 @@ py-trivy:
       fi
       trivy fs ${PYTHON_TRIVY_ARGS} --format table --exit-code 0 $PYTHON_PROJECT_DIR
       trivy fs ${PYTHON_TRIVY_ARGS} --format json --output reports/trivy-python.json --exit-code 1 $PYTHON_PROJECT_DIR
-  
   artifacts:
     name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
     expire_in: 1 day
@@ -735,62 +848,30 @@ py-trivy:
       when: manual
       allow_failure: true
 
-###############################################################################################
-#                                      package stage                                           #
-###############################################################################################
-
-# (on tag creation): create packages as artifacts
-py-package:
-  extends: .python-base
-  stage: package-build
-  script:
-    - _package
-  artifacts:
-    paths:
-      - $PYTHON_PROJECT_DIR/dist/*.tar.gz
-      - $PYTHON_PROJECT_DIR/dist/*.whl
-  rules:
-    # on tags
-    - if: '$CI_COMMIT_TAG'
-    - if: '$PYTHON_FORCE_PACKAGE == "true"'
-
-###############################################################################################
-#                                      publish stage                                           #
-###############################################################################################
-
-# (on tag creation): performs a release
-py-publish:
-  extends: .python-base
-  stage: publish
-  script:
-    - assert_defined "$TWINE_USERNAME" 'Missing required env $TWINE_USERNAME'
-    - assert_defined "$TWINE_PASSWORD" 'Missing required env $TWINE_PASSWORD'
-    - _publish
-  rules:
-    # on tags with $PYTHON_PUBLISH_ENABLED set
-    - if: '$PYTHON_PUBLISH_ENABLED == "true" && $CI_COMMIT_TAG'
-
 # (manual from master branch): triggers a release (tag creation)
 py-release:
   extends: .python-base
   stage: publish
   script:
-    - git config --global user.email '$GITLAB_USER_EMAIL'
-    - git config --global user.name '$GITLAB_USER_LOGIN'
+    - git config --global user.email "$GITLAB_USER_EMAIL"
+    - git config --global user.name "$GITLAB_USER_LOGIN"
     - git checkout -B $CI_BUILD_REF_NAME
+    - configure_scm_auth
     - _release
-    - git_url_base=`echo ${CI_REPOSITORY_URL} | cut -d\@ -f2`
-    - git push https://${RELEASE_USERNAME}:${RELEASE_ACCESS_TOKEN}@${git_url_base} --tags
-    - git push https://${RELEASE_USERNAME}:${RELEASE_ACCESS_TOKEN}@${git_url_base} $CI_BUILD_REF_NAME
+  artifacts:
+    paths:
+      - $PYTHON_PROJECT_DIR/dist/*
   rules:
     # exclude merge requests
     - if: $CI_MERGE_REQUEST_ID
       when: never
-    # on production branch(es): manual & non-blocking if $RELEASE_USERNAME is set
-    - if: '$RELEASE_USERNAME && $CI_COMMIT_REF_NAME =~ $PROD_REF'
-      when: manual
-      allow_failure: true
-    # on integration branch(es): manual & non-blocking if $RELEASE_USERNAME is set
-    - if: '$RELEASE_USERNAME && $CI_COMMIT_REF_NAME =~ $INTEG_REF'
+    # exclude if $PYTHON_RELEASE_ENABLED not set
+    - if: '$PYTHON_RELEASE_ENABLED != "true"'
+      when: never
+    # exclude on non-prod, non-integ branches
+    - if: '$CI_COMMIT_REF_NAME !~ $PROD_REF && $CI_COMMIT_REF_NAME !~ $INTEG_REF'
+      when: never
+    # else: manual
+    - if: '$PYTHON_RELEASE_ENABLED == "true"' # useless but prevents GitLab warning
       when: manual
       allow_failure: true
-- 
GitLab