Select Git revision
-
Andrea Franchini authoredAndrea Franchini authored
gitlab-ci-python.yml 53.50 KiB
# =========================================================================================
# Copyright (C) 2021 Orange & contributors
#
# This program is free software; you can redistribute it and/or modify it under the terms
# of the GNU Lesser General Public License as published by the Free Software Foundation;
# either version 3 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along with this
# program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth
# Floor, Boston, MA 02110-1301, USA.
# =========================================================================================
spec:
inputs:
image:
description: The Docker image used to run Python - **set the version required by your project**
default: registry.hub.docker.com/library/python:3-slim
project-dir:
description: Python project root directory
default: .
build-system:
description: Python build-system to use to install dependencies, build and package the project
options:
- auto
- setuptools
- poetry
- pipenv
- reqfile
- uv
default: auto
reqs-file:
description: |-
Main requirements file _(relative to `$PYTHON_PROJECT_DIR`)_
For [Requirements Files](https://pip.pypa.io/en/stable/user_guide/#requirements-files) build-system only
default: requirements.txt
extra-reqs-files:
description: |-
Extra dev requirements file(s) to install _(relative to `$PYTHON_PROJECT_DIR`)_
For [Requirements Files](https://pip.pypa.io/en/stable/user_guide/#requirements-files) build-system only
default: requirements-dev.txt
compile-args:
description: '[`compileall` CLI options](https://docs.python.org/3/library/compileall.html)'
default: '*'
pip-opts:
description: pip extra [options](https://pip.pypa.io/en/stable/cli/pip/#general-options)
default: ''
extra-deps:
description: |-
Extra sets of dependencies to install
For [Setuptools](https://setuptools.pypa.io/en/latest/userguide/dependency_management.html?highlight=extras#optional-dependencies) or [Poetry](https://python-poetry.org/docs/pyproject/#extras) only
default: ''
package-enabled:
description: Enable package
type: boolean
default: false
pylint-enabled:
description: Enable pylint
type: boolean
default: false
pylint-args:
description: Additional [pylint CLI options](http://pylint.pycqa.org/en/latest/user_guide/run.html#command-line-options)
default: ''
pylint-files:
description: Files or directories to analyse
default: ''
unittest-enabled:
description: Enable unittest
type: boolean
default: false
unittest-args:
description: Additional xmlrunner/unittest CLI options
default: ''
pytest-enabled:
description: Enable pytest
type: boolean
default: false
pytest-args:
description: Additional [pytest](https://docs.pytest.org/en/stable/usage.html) or [pytest-cov](https://github.com/pytest-dev/pytest-cov#usage) CLI options
default: ''
nosetests-enabled:
description: Enable nose
type: boolean
default: false
nosetests-args:
description: Additional [nose CLI options](https://nose.readthedocs.io/en/latest/usage.html#options)
default: ''
bandit-enabled:
description: Enable Bandit
type: boolean
default: false
bandit-args:
description: Additional [Bandit CLI options](https://github.com/PyCQA/bandit#usage)
default: --recursive .
trivy-disabled:
description: Disable Trivy
type: boolean
default: false
trivy-dist-url:
description: |-
Url to the `tar.gz` package for `linux_amd64` of Trivy to use
_When unset, the latest version will be used_
default: ''
trivy-args:
description: Additional [Trivy CLI options](https://aquasecurity.github.io/trivy/latest/docs/references/configuration/cli/trivy_filesystem/)
default: --ignore-unfixed --pkg-types library --detection-priority comprehensive
sbom-disabled:
description: Disable Software Bill of Materials
type: boolean
default: false
sbom-syft-url:
description: |-
Url to the `tar.gz` package for `linux_amd64` of Syft to use
_When unset, the latest version will be used_
default: ''
sbom-name:
description: Component name of the emitted SBOM
default: $CI_PROJECT_PATH/$PYTHON_PROJECT_DIR
sbom-opts:
description: Options for syft used for SBOM analysis
default: --override-default-catalogers python-package-cataloger
release-enabled:
description: Enable Release
type: boolean
default: false
auto-release-enabled:
description: When set the job start automatically on production branch. When not set (default), the job is manual. Note that this behavior also depends on release-enabled being set.
type: boolean
default: false
publish-enabled:
description: Enable Publish Package
type: boolean
default: false
release-next:
description: 'The part of the version to increase (one of: `major`, `minor`, `patch`)'
options:
- ''
- major
- minor
- patch
default: minor
semrel-release-disabled:
description: Disable semantic-release integration
type: boolean
default: false
release-commit-message:
description: The Git commit message to use on the release commit. This is templated using the [Python Format String Syntax](http://docs.python.org/2/library/string.html#format-string-syntax). Available in the template context are current_version and new_version.
default: "chore(python-release): {current_version} \u2192 {new_version}"
repository-url:
description: |-
Target PyPI repository to publish packages.
_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
black-enabled:
description: Enable black
type: boolean
default: false
isort-enabled:
description: Enable isort
type: boolean
default: false
ruff-enabled:
description: Enable Ruff
type: boolean
default: false
ruff-args:
description: Additional [Ruff Linter CLI options](https://docs.astral.sh/ruff/configuration/#full-command-line-interface)
default: ""
ruff-format-enabled:
description: Enable Ruff
type: boolean
default: false
mypy-enabled:
description: Enable mypy
type: boolean
default: false
mypy-args:
description: Additional [mypy CLI options](https://mypy.readthedocs.io/en/stable/command_line.html)
default: ""
mypy-files:
description: Files or directories to analyse
default: ''
---
# default workflow rules: Merge Request pipelines
workflow:
rules:
# prevent MR pipeline originating from production or integration branch(es)
- if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ $PROD_REF || $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ $INTEG_REF'
when: never
# on non-prod, non-integration branches: prefer MR pipeline over branch pipeline
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_COMMIT_REF_NAME !~ $PROD_REF && $CI_COMMIT_REF_NAME !~ $INTEG_REF'
when: never
- if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*tag(,[^],]*)*\]/" && $CI_COMMIT_TAG'
when: never
- if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*branch(,[^],]*)*\]/" && $CI_COMMIT_BRANCH'
when: never
- if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*mr(,[^],]*)*\]/" && $CI_MERGE_REQUEST_ID'
when: never
- if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*default(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME =~ $CI_DEFAULT_BRANCH'
when: never
- if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*prod(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME =~ $PROD_REF'
when: never
- if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*integ(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME =~ $INTEG_REF'
when: never
- if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*dev(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME !~ $PROD_REF && $CI_COMMIT_REF_NAME !~ $INTEG_REF'
when: never
- when: always
# test job prototype: implement adaptive pipeline rules
.test-policy:
rules:
# on tag: auto & failing
- if: $CI_COMMIT_TAG
# on ADAPTIVE_PIPELINE_DISABLED: auto & failing
- if: '$ADAPTIVE_PIPELINE_DISABLED == "true"'
# on production or integration branch(es): auto & failing
- if: '$CI_COMMIT_REF_NAME =~ $PROD_REF || $CI_COMMIT_REF_NAME =~ $INTEG_REF'
# early stage (dev branch, no MR): manual & non-failing
- if: '$CI_MERGE_REQUEST_ID == null && $CI_OPEN_MERGE_REQUESTS == null'
when: manual
allow_failure: true
# Draft MR: auto & non-failing
- if: '$CI_MERGE_REQUEST_TITLE =~ /^Draft:.*/'
allow_failure: true
# else (Ready MR): auto & failing
- when: on_success
# software delivery job prototype: run on production and integration branches + release pipelines
.delivery-policy:
rules:
# on tag with release pattern
- if: '$CI_COMMIT_TAG =~ $RELEASE_REF'
# on production or integration branch(es)
- if: '$CI_COMMIT_REF_NAME =~ $PROD_REF || $CI_COMMIT_REF_NAME =~ $INTEG_REF'
variables:
# Global TBC SBOM Mode (onrelease -> only generate SBOMs for releases, always -> generate SBOMs for all refs)
TBC_SBOM_MODE: "onrelease"
# Default Docker image (can be overridden)
PYTHON_IMAGE: $[[ inputs.image ]]
# Default Python project root directory
PYTHON_PROJECT_DIR: $[[ inputs.project-dir ]]
PYTHON_REQS_FILE: $[[ inputs.reqs-file ]]
PYTHON_EXTRA_REQS_FILES: $[[ inputs.extra-reqs-files ]]
# default production ref name (pattern)
PROD_REF: '/^(master|main)$/'
# default integration ref name (pattern)
INTEG_REF: '/^develop$/'
# default release tag name (pattern)
RELEASE_REF: '/^v?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9-\.]+)?(\+[a-zA-Z0-9-\.]+)?$/'
# compileall
PYTHON_COMPILE_ARGS: $[[ inputs.compile-args ]]
BANDIT_ARGS: $[[ inputs.bandit-args ]]
# Trivy tool
PYTHON_TRIVY_DISABLED: $[[ inputs.trivy-disabled ]]
PYTHON_TRIVY_DIST_URL: $[[ inputs.trivy-dist-url ]]
PYTHON_TRIVY_ARGS: $[[ inputs.trivy-args ]]
PYTHON_SBOM_NAME: $[[ inputs.sbom-name ]]
PYTHON_SBOM_OPTS: $[[ inputs.sbom-opts ]]
PYTHON_RELEASE_NEXT: $[[ inputs.release-next ]]
PYTHON_RELEASE_COMMIT_MESSAGE: $[[ inputs.release-commit-message ]]
# 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
PYTHON_REPOSITORY_URL: $[[ inputs.repository-url ]]
PYTHON_REPOSITORY_USERNAME: gitlab-ci-token
PYTHON_REPOSITORY_PASSWORD: $CI_JOB_TOKEN
PYTHON_BUILD_SYSTEM: $[[ inputs.build-system ]]
PIP_OPTS: $[[ inputs.pip-opts ]]
PYTHON_EXTRA_DEPS: $[[ inputs.extra-deps ]]
PYTHON_PACKAGE_ENABLED: $[[ inputs.package-enabled ]]
PYLINT_ENABLED: $[[ inputs.pylint-enabled ]]
PYLINT_ARGS: $[[ inputs.pylint-args ]]
PYLINT_FILES: $[[ inputs.pylint-files ]]
UNITTEST_ENABLED: $[[ inputs.unittest-enabled ]]
UNITTEST_ARGS: $[[ inputs.unittest-args ]]
PYTEST_ENABLED: $[[ inputs.pytest-enabled ]]
PYTEST_ARGS: $[[ inputs.pytest-args ]]
NOSETESTS_ARGS: $[[ inputs.nosetests-args ]]
PYTHON_SBOM_SYFT_URL: $[[ inputs.sbom-syft-url ]]
PYTHON_SEMREL_RELEASE_DISABLED: $[[ inputs.semrel-release-disabled ]]
NOSETESTS_ENABLED: $[[ inputs.nosetests-enabled ]]
BANDIT_ENABLED: $[[ inputs.bandit-enabled ]]
PYTHON_SBOM_DISABLED: $[[ inputs.sbom-disabled ]]
PYTHON_RELEASE_ENABLED: $[[ inputs.release-enabled ]]
PYTHON_PUBLISH_ENABLED: $[[ inputs.publish-enabled ]]
PYTHON_AUTO_RELEASE_ENABLED: $[[ inputs.auto-release-enabled ]]
PYTHON_BLACK_ENABLED: $[[ inputs.black-enabled ]]
PYTHON_ISORT_ENABLED: $[[ inputs.isort-enabled ]]
RUFF_ENABLED: $[[ inputs.ruff-enabled ]]
RUFF_ARGS: $[[ inputs.ruff-args ]]
RUFF_FORMAT_ENABLED: $[[ inputs.ruff-format-enabled ]]
MYPY_ENABLED: $[[ inputs.mypy-enabled ]]
MYPY_ARGS: $[[ inputs.mypy-args ]]
MYPY_FILES: $[[ inputs.mypy-files ]]
.python-scripts: &python-scripts |
# BEGSCRIPT
set -e
function log_info() {
echo -e "[\\e[1;94mINFO\\e[0m] $*"
}
function log_warn() {
echo -e "[\\e[1;93mWARN\\e[0m] $*"
}
function log_error() {
echo -e "[\\e[1;91mERROR\\e[0m] $*"
}
function fail() {
log_error "$*"
exit 1
}
function assert_defined() {
if [[ -z "$1" ]]
then
log_error "$2"
exit 1
fi
}
function install_ca_certs() {
certs=$1
if [[ -z "$certs" ]]
then
return
fi
# import in system
if echo "$certs" >> /etc/ssl/certs/ca-certificates.crt
then
log_info "CA certificates imported in \\e[33;1m/etc/ssl/certs/ca-certificates.crt\\e[0m"
fi
if echo "$certs" >> /etc/ssl/cert.pem
then
log_info "CA certificates imported in \\e[33;1m/etc/ssl/cert.pem\\e[0m"
fi
# import in Java keystore (if keytool command found)
if command -v keytool > /dev/null
then
# shellcheck disable=SC2046
javahome=${JAVA_HOME:-$(dirname $(readlink -f $(command -v java)))/..}
# shellcheck disable=SC2086
keystore=${JAVA_KEYSTORE_PATH:-$(ls -1 $javahome/jre/lib/security/cacerts 2>/dev/null || ls -1 $javahome/lib/security/cacerts 2>/dev/null || echo "")}
if [[ -f "$keystore" ]]
then
storepass=${JAVA_KEYSTORE_PASSWORD:-changeit}
nb_certs=$(echo "$certs" | grep -c 'END CERTIFICATE')
log_info "importing $nb_certs certificates in Java keystore \\e[33;1m$keystore\\e[0m..."
for idx in $(seq 0 $((nb_certs - 1)))
do
# TODO: use keytool option -trustcacerts ?
if echo "$certs" | awk "n==$idx { print }; /END CERTIFICATE/ { n++ }" | keytool -noprompt -import -alias "imported CA Cert $idx" -keystore "$keystore" -storepass "$storepass"
then
log_info "... CA certificate [$idx] successfully imported"
else
log_warn "... Failed importing CA certificate [$idx]: abort"
return
fi
done
else
log_warn "Java keystore \\e[33;1m$keystore\\e[0m not found: could not import CA certificates"
fi
fi
# variable REQUESTS_CA_BUNDLE for Python if Python installed
if command -v python > /dev/null
then
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
log_info "Python requests \\e[33;1m\$REQUESTS_CA_BUNDLE\\e[0m variable set"
fi
}
function unscope_variables() {
_scoped_vars=$(env | awk -F '=' "/^scoped__[a-zA-Z0-9_]+=/ {print \$1}" | sort)
if [[ -z "$_scoped_vars" ]]; then return; fi
log_info "Processing scoped variables..."
for _scoped_var in $_scoped_vars
do
_fields=${_scoped_var//__/:}
_condition=$(echo "$_fields" | cut -d: -f3)
case "$_condition" in
if) _not="";;
ifnot) _not=1;;
*)
log_warn "... unrecognized condition \\e[1;91m$_condition\\e[0m in \\e[33;1m${_scoped_var}\\e[0m"
continue
;;
esac
_target_var=$(echo "$_fields" | cut -d: -f2)
_cond_var=$(echo "$_fields" | cut -d: -f4)
_cond_val=$(eval echo "\$${_cond_var}")
_test_op=$(echo "$_fields" | cut -d: -f5)
case "$_test_op" in
defined)
if [[ -z "$_not" ]] && [[ -z "$_cond_val" ]]; then continue;
elif [[ "$_not" ]] && [[ "$_cond_val" ]]; then continue;
fi
;;
equals|startswith|endswith|contains|in|equals_ic|startswith_ic|endswith_ic|contains_ic|in_ic)
# comparison operator
# sluggify actual value
_cond_val=$(echo "$_cond_val" | tr '[:punct:]' '_')
# retrieve comparison value
_cmp_val_prefix="scoped__${_target_var}__${_condition}__${_cond_var}__${_test_op}__"
_cmp_val=${_scoped_var#"$_cmp_val_prefix"}
# manage 'ignore case'
if [[ "$_test_op" == *_ic ]]
then
# lowercase everything
_cond_val=$(echo "$_cond_val" | tr '[:upper:]' '[:lower:]')
_cmp_val=$(echo "$_cmp_val" | tr '[:upper:]' '[:lower:]')
fi
case "$_test_op" in
equals*)
if [[ -z "$_not" ]] && [[ "$_cond_val" != "$_cmp_val" ]]; then continue;
elif [[ "$_not" ]] && [[ "$_cond_val" == "$_cmp_val" ]]; then continue;
fi
;;
startswith*)
if [[ -z "$_not" ]] && [[ "$_cond_val" != "$_cmp_val"* ]]; then continue;
elif [[ "$_not" ]] && [[ "$_cond_val" == "$_cmp_val"* ]]; then continue;
fi
;;
endswith*)
if [[ -z "$_not" ]] && [[ "$_cond_val" != *"$_cmp_val" ]]; then continue;
elif [[ "$_not" ]] && [[ "$_cond_val" == *"$_cmp_val" ]]; then continue;
fi
;;
contains*)
if [[ -z "$_not" ]] && [[ "$_cond_val" != *"$_cmp_val"* ]]; then continue;
elif [[ "$_not" ]] && [[ "$_cond_val" == *"$_cmp_val"* ]]; then continue;
fi
;;
in*)
if [[ -z "$_not" ]] && [[ "__${_cmp_val}__" != *"__${_cond_val}__"* ]]; then continue;
elif [[ "$_not" ]] && [[ "__${_cmp_val}__" == *"__${_cond_val}__"* ]]; then continue;
fi
;;
esac
;;
*)
log_warn "... unrecognized test operator \\e[1;91m${_test_op}\\e[0m in \\e[33;1m${_scoped_var}\\e[0m"
continue
;;
esac
# matches
_val=$(eval echo "\$${_target_var}")
log_info "... apply \\e[32m${_target_var}\\e[0m from \\e[32m\$${_scoped_var}\\e[0m${_val:+ (\\e[33;1moverwrite\\e[0m)}"
_val=$(eval echo "\$${_scoped_var}")
export "${_target_var}"="${_val}"
done
log_info "... done"
}
# evaluate and export a secret
# - $1: secret variable name
function eval_secret() {
name=$1
value=$(eval echo "\$${name}")
case "$value" in
@b64@*)
decoded=$(mktemp)
errors=$(mktemp)
if echo "$value" | cut -c6- | base64 -d > "${decoded}" 2> "${errors}"
then
# shellcheck disable=SC2086
export ${name}="$(cat ${decoded})"
log_info "Successfully decoded base64 secret \\e[33;1m${name}\\e[0m"
else
fail "Failed decoding base64 secret \\e[33;1m${name}\\e[0m:\\n$(sed 's/^/... /g' "${errors}")"
fi
;;
@hex@*)
decoded=$(mktemp)
errors=$(mktemp)
if echo "$value" | cut -c6- | sed 's/\([0-9A-F]\{2\}\)/\\\\x\1/gI' | xargs printf > "${decoded}" 2> "${errors}"
then
# shellcheck disable=SC2086
export ${name}="$(cat ${decoded})"
log_info "Successfully decoded hexadecimal secret \\e[33;1m${name}\\e[0m"
else
fail "Failed decoding hexadecimal secret \\e[33;1m${name}\\e[0m:\\n$(sed 's/^/... /g' "${errors}")"
fi
;;
@url@*)
url=$(echo "$value" | cut -c6-)
if command -v curl > /dev/null
then
decoded=$(mktemp)
errors=$(mktemp)
if curl -s -S -f --connect-timeout 5 -o "${decoded}" "$url" 2> "${errors}"
then
# shellcheck disable=SC2086
export ${name}="$(cat ${decoded})"
log_info "Successfully curl'd secret \\e[33;1m${name}\\e[0m"
else
log_warn "Failed getting secret \\e[33;1m${name}\\e[0m:\\n$(sed 's/^/... /g' "${errors}")"
fi
elif command -v wget > /dev/null
then
decoded=$(mktemp)
errors=$(mktemp)
if wget -T 5 -O "${decoded}" "$url" 2> "${errors}"
then
# shellcheck disable=SC2086
export ${name}="$(cat ${decoded})"
log_info "Successfully wget'd secret \\e[33;1m${name}\\e[0m"
else
log_warn "Failed getting secret \\e[33;1m${name}\\e[0m:\\n$(sed 's/^/... /g' "${errors}")"
fi
elif command -v python3 > /dev/null
then
decoded=$(mktemp)
errors=$(mktemp)
# shellcheck disable=SC2086
if python3 -c "import urllib.request ; urllib.request.urlretrieve(\"$url\",\"${decoded}\")" > "${errors}" 2>&1
then
export ${name}="$(cat ${decoded})"
log_info "Successfully fetched secret \\e[33;1m${name}\\e[0m"
else
log_warn "Failed getting secret \\e[33;1m${name}\\e[0m:\\n$(sed 's/^/... /g' "${errors}")"
fi
else
log_warn "Couldn't get secret \\e[33;1m${name}\\e[0m: no http client found"
fi
;;
esac
}
function eval_all_secrets() {
encoded_vars=$(env | grep -v '^scoped__' | awk -F '=' '/^[a-zA-Z0-9_]*=@(b64|hex|url)@/ {print $1}')
for var in $encoded_vars
do
eval_secret "$var"
done
}
function maybe_install_packages() {
if command -v apt-get > /dev/null
then
# Debian
if ! dpkg --status "$@" > /dev/null
then
apt-get update
apt-get install --no-install-recommends --yes --quiet "$@"
fi
elif command -v apk > /dev/null
then
# Alpine
if ! apk info --installed "$@" > /dev/null
then
apk add --no-cache "$@"
fi
else
log_error "... didn't find any supported package manager to install $*"
exit 1
fi
}
function guess_build_system() {
case "${PYTHON_BUILD_SYSTEM:-auto}" in
auto)
;;
poetry*|setuptools*|pipenv*|uv*)
log_info "--- Build system explicitly declared: ${PYTHON_BUILD_SYSTEM}"
return
;;
reqfile)
log_info "--- Build system explicitly declared: requirements file"
return
;;
*)
log_warn "--- Unknown declared build system: \\e[33;1m${PYTHON_BUILD_SYSTEM}\\e[0m: please read template doc"
;;
esac
if [[ -f "${PYTHON_REQS_FILE}" ]]
then
log_info "--- Build system auto-detected: requirements file"
export PYTHON_BUILD_SYSTEM="reqfile"
return
fi
if [[ -f "uv.lock" ]]
then
if [[ -f "pyproject.toml" ]]
then
log_info "--- Build system auto-detected: uv (uv.lock and pyproject.toml)"
export PYTHON_BUILD_SYSTEM="uv"
return
fi
log_error "--- Build system auto-detected: uv (uv.lock) but no pyproject.toml found: please read template doc"
fi
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)
case "$build_backend" in
"")
log_info "--- Build system auto-detection... pyproject.toml found but no 'build-backend' specified: continue..."
;;
poetry.core.masonry.api)
log_info "--- Build system auto-detected: PEP 517 with Poetry backend"
export PYTHON_BUILD_SYSTEM="poetry"
return
;;
setuptools.build_meta)
log_info "--- Build system auto-detected: PEP 517 with Setuptools backend"
export PYTHON_BUILD_SYSTEM="setuptools"
return
;;
*)
log_error "--- Build system auto-detected: PEP 517 with unsupported backend \\e[33;1m${build_backend}\\e[0m: please read template doc"
exit 1
;;
esac
fi
if [[ -f "setup.py" ]]
then
log_info "--- Build system auto-detected: Setuptools (legacy)"
export PYTHON_BUILD_SYSTEM="setuptools"
elif [[ -f "Pipfile" ]]
then
log_info "--- Build system auto-detected: Pipenv"
export PYTHON_BUILD_SYSTEM="pipenv"
else
log_error "--- Build system auto-detect failed: please read template doc"
exit 1
fi
}
function maybe_install_poetry() {
if [[ "$PYTHON_BUILD_SYSTEM" =~ ^poetry ]] && ! command -v poetry > /dev/null
then
# shellcheck disable=SC2086
pip install ${PIP_OPTS} "$PYTHON_BUILD_SYSTEM"
fi
}
function maybe_install_uv() {
if [[ "$PYTHON_BUILD_SYSTEM" =~ ^uv ]] && ! command -v uv > /dev/null
then
# shellcheck disable=SC2086
pip install ${PIP_OPTS} "$PYTHON_BUILD_SYSTEM"
fi
}
# install requirements
function install_requirements() {
case "$PYTHON_BUILD_SYSTEM" in
poetry*)
if [[ ! -f "poetry.lock" ]]; then
log_warn "Using Poetry but \\e[33;1mpoetry.lock\\e[0m file not found: you shall commit it with your project files"
fi
maybe_install_poetry
poetry install ${PYTHON_EXTRA_DEPS:+--extras "$PYTHON_EXTRA_DEPS"}
;;
setuptools*)
# shellcheck disable=SC2086
pip install ${PIP_OPTS} "$PYTHON_BUILD_SYSTEM"
# shellcheck disable=SC2086
pip install ${PIP_OPTS} ".${PYTHON_EXTRA_DEPS:+[$PYTHON_EXTRA_DEPS]}"
;;
pipenv*)
# shellcheck disable=SC2086
pip install ${PIP_OPTS} "$PYTHON_BUILD_SYSTEM"
if [[ ! -f "Pipfile.lock" ]]; then
log_warn "Using Pipenv but \\e[33;1mPipfile.lock\\e[0m file not found: you shall commit it with your project files"
pipenv install --dev --system
else
pipenv sync --dev --system
fi
;;
reqfile)
if [[ -f "${PYTHON_REQS_FILE}" ]]; then
log_info "--- installing main requirements from \\e[33;1m${PYTHON_REQS_FILE}\\e[0m"
# shellcheck disable=SC2086
pip install ${PIP_OPTS} -r "${PYTHON_REQS_FILE}"
# shellcheck disable=SC2086
found_reqs_files=$(eval ls -1 $PYTHON_EXTRA_REQS_FILES 2>/dev/null || echo "")
# shellcheck disable=SC2116
for extrareqsfile in $(echo "$found_reqs_files"); do
log_info "--- installing extra requirements from \\e[33;1m${extrareqsfile}\\e[0m"
# shellcheck disable=SC2086
pip install ${PIP_OPTS} -r "${extrareqsfile}"
done
else
log_warn "--- requirements build system defined, but no ${PYTHON_REQS_FILE} file found"
fi
;;
uv*)
if [[ ! -f "uv.lock" ]]; then
log_warn "Using uv but \\e[33;1muv.lock\\e[0m file not found: you shall commit it with your project files"
fi
maybe_install_uv
uv sync --frozen ${PYTHON_EXTRA_DEPS:+--extra "$PYTHON_EXTRA_DEPS"}
;;
esac
}
function _run() {
if [[ "$PYTHON_BUILD_SYSTEM" =~ ^poetry ]]
then
maybe_install_poetry
poetry run "$@"
elif [[ "$PYTHON_BUILD_SYSTEM" =~ ^uv ]]
then
maybe_install_uv
uv run "$@"
else
"$@"
fi
}
function _python() {
_run python "$@"
}
function _pip() {
cmd=$1
shift
if [[ "$PYTHON_BUILD_SYSTEM" =~ ^uv ]]
then
maybe_install_uv
# shellcheck disable=SC2086
uv pip "$cmd" ${PIP_OPTS} "$@"
else
# shellcheck disable=SC2086
_run pip "$cmd" ${PIP_OPTS} "$@"
fi
}
function py_package() {
if [[ "$PYTHON_BUILD_SYSTEM" =~ ^poetry ]]
then
maybe_install_poetry
poetry build
elif [[ "$PYTHON_BUILD_SYSTEM" =~ ^uv ]]
then
maybe_install_uv
uv build
else
# shellcheck disable=SC2086
pip install ${PIP_OPTS} build
python -m build
fi
}
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
fi
}
function py_release() {
# 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: bump-my-version (+ Git commit & tag)
if [[ "$PYTHON_BUILD_SYSTEM" =~ ^poetry ]]
then
maybe_install_poetry
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 commit and tag
git add pyproject.toml
# emulate bump-my-version to generate commit message
py_commit_message=$(python -c "print('$PYTHON_RELEASE_COMMIT_MESSAGE'.format(current_version='$py_cur_version', new_version='$py_next_version'))")
git commit -m "$py_commit_message"
git tag "$py_next_version"
elif [[ "$PYTHON_BUILD_SYSTEM" =~ ^uv ]]
then
maybe_install_uv
if [[ -z "$py_next_version" ]]
then
# quick version waiting for uv to manage bump
# related uv MR https://github.com/astral-sh/uv/pull/7248#issuecomment-2395465334
mkdir -p -m 777 tbc_tmp
uvx --from toml-cli toml get --toml-path pyproject.toml project.version > tbc_tmp/version.txt
py_cur_version=$(cat tbc_tmp/version.txt)
py_release_part="$PYTHON_RELEASE_NEXT"
log_info "[bump-my-version] increase \\e[1;94m${py_release_part}\\e[0m (from current \\e[1;94m${py_cur_version}\\e[0m)"
uvx bump-my-version bump ${TRACE+--verbose} --current-version "$py_cur_version" "$py_release_part" tbc_tmp/version.txt
py_next_version=$(cat tbc_tmp/version.txt)
rm -fr tbc_tmp/version.txt
fi
log_info "[uv] change version \\e[1;94m${py_cur_version}\\e[0m → \\e[1;94m${py_next_version}\\e[0m"
uvx --from toml-cli toml set --toml-path pyproject.toml project.version "$py_next_version"
# Git commit and tag
git add pyproject.toml
# emulate bump-my-version to generate commit message
py_commit_message=$(python -c "print('$PYTHON_RELEASE_COMMIT_MESSAGE'.format(current_version='$py_cur_version', new_version='$py_next_version'))")
git commit -m "$py_commit_message"
git tag --force "$py_next_version"
else
# Setuptools / bump-my-version
# shellcheck disable=SC2086
pip install ${PIP_OPTS} bump-my-version
if [[ "$py_next_version" ]]
then
# explicit release version (semantic-release)
log_info "[bumpversion] change version \\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
if [[ ! "$py_cur_version" && ! -f ".bumpversion.cfg" && ! -f ".bumpversion.toml" && ! -f "pyproject.toml" && ! -f "setup.cfg" ]]
then
log_warn "Current version not defined and no version file found: please set initial version in .bumpversion.toml, .bumpversion.cfg, setup.cfg or pyproject.toml ([tool.bumpversion] section)"
fi
bump-my-version bump ${TRACE+--verbose} --current-version "${py_cur_version:-${PYTHON_RELEASE_START_VERSION:-0.0.0}}" --new-version "$py_next_version" --commit ${PYTHON_RELEASE_COMMIT_MESSAGE:+--message "$PYTHON_RELEASE_COMMIT_MESSAGE"} --tag --tag-name "{new_version}" "$py_release_part"
elif [[ -f ".bumpversion.cfg" || -f ".bumpversion.toml" || -f "pyproject.toml" || -f "setup.cfg" ]]
then
if [[ ! -f ".bumpversion.cfg" && ! -f ".bumpversion.toml" && ! -f "setup.cfg" ]] # pyproject.toml case
then
if ! grep -q '^[[:space:]]*\[tool\.bumpversion\]' "pyproject.toml"
then
log_warn "Current version not defined and no version file found: please set initial version in pyproject.toml ([tool.bumpversion] section)"
fi
fi
# current version shall be set in bump-my-version config file
py_release_part="$PYTHON_RELEASE_NEXT"
log_info "[bump-my-version bump] increase \\e[1;94m${py_release_part}\\e[0m"
bump-my-version bump ${TRACE+--verbose} --commit ${PYTHON_RELEASE_COMMIT_MESSAGE:+--message "$PYTHON_RELEASE_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 "[bump-my-version] increase \\e[1;94m${py_release_part}\\e[0m (from current \\e[1;94m${py_cur_version}\\e[0m)"
bump-my-version bump ${TRACE+--verbose} --current-version "$py_cur_version" --commit ${PYTHON_RELEASE_COMMIT_MESSAGE:+--message "$PYTHON_RELEASE_COMMIT_MESSAGE"} --tag --tag-name "{new_version}" "$py_release_part" setup.py
else
log_error "--- setup.py, .bumpversion.toml, pyproject.toml, .bumpversion.cfg(deprecated) or setup.cfg(deprecated) file required to retrieve current version: cannot perform release"
exit 1
fi
fi
# 3: Git commit, tag and push
log_info "--- git push commit and tag..."
git push "$git_auth_url" "$CI_COMMIT_REF_NAME"
git push "$git_auth_url" --tags
}
function py_publish() {
if [[ "$PYTHON_BUILD_SYSTEM" =~ ^poetry ]]
then
maybe_install_poetry
if [[ "$PYTHON_PACKAGE_ENABLED" != "true" ]]
then
log_info "--- build packages (poetry)..."
poetry build ${TRACE+--verbose}
fi
log_info "--- publish packages (poetry) to $PYTHON_REPOSITORY_URL with user $PYTHON_REPOSITORY_USERNAME..."
poetry config repositories.user_defined "$PYTHON_REPOSITORY_URL"
poetry publish ${TRACE+--verbose} --username "$PYTHON_REPOSITORY_USERNAME" --password "$PYTHON_REPOSITORY_PASSWORD" --repository user_defined
elif [[ "$PYTHON_BUILD_SYSTEM" =~ ^uv ]]
then
maybe_install_uv
if [[ "$PYTHON_PACKAGE_ENABLED" != "true" ]]
then
log_info "--- build packages (uv)..."
uv build ${TRACE+--verbose}
fi
log_info "--- publish packages (uv) to $PYTHON_REPOSITORY_URL with user $PYTHON_REPOSITORY_USERNAME..."
uv publish ${TRACE+--verbose} --username "$PYTHON_REPOSITORY_USERNAME" --password "$PYTHON_REPOSITORY_PASSWORD" --publish-url "$PYTHON_REPOSITORY_URL"
else
# shellcheck disable=SC2086
pip install ${PIP_OPTS} build twine
if [[ "$PYTHON_PACKAGE_ENABLED" != "true" ]]
then
log_info "--- build packages (build)..."
rm -rf dist
python -m build
fi
log_info "--- publish packages (twine) to $PYTHON_REPOSITORY_URL with user $PYTHON_REPOSITORY_USERNAME..."
twine upload ${TRACE+--verbose} --username "$PYTHON_REPOSITORY_USERNAME" --password "$PYTHON_REPOSITORY_PASSWORD" --repository-url "$PYTHON_REPOSITORY_URL" dist/*
fi
}
function github_get_latest_version() {
if command -v curl &> /dev/null
then
curl -sSf -I "https://github.com/$1/releases/latest" | awk -F '/' -v RS='\r\n' '/location:/ {print $NF}'
elif command -v python3 &> /dev/null
then
python3 -c "import urllib.request;url='https://github.com/$1/releases/latest';opener=urllib.request.build_opener(type('NoRedirection', (urllib.request.HTTPErrorProcessor,), {'http_response': lambda self, req, resp: resp, 'https_response': lambda self, req, resp: resp})());req=urllib.request.Request(url, method='HEAD');print(opener.open(req).headers.get('Location').split('/')[-1])"
else
fail "curl or python3 required"
fi
}
unscope_variables
eval_all_secrets
# ENDSCRIPT
###############################################################################################
# stages definition #
###############################################################################################
stages:
- build
- test
- package-build
- package-test
- infra
- deploy
- acceptance
- publish
- infra-prod
- production
###############################################################################################
# Generic python jobs #
###############################################################################################
.python-base:
image: $PYTHON_IMAGE
services:
- name: "$TBC_TRACKING_IMAGE"
command: ["--service", "python", "7.9.1"]
variables:
# set local cache dir; most Python tools honour XDG specs
XDG_CACHE_HOME: "$CI_PROJECT_DIR/.cache"
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
POETRY_CACHE_DIR: "$CI_PROJECT_DIR/.cache/poetry"
PIPENV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pipenv"
UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv"
POETRY_VIRTUALENVS_IN_PROJECT: "false"
cache:
key: "$CI_COMMIT_REF_SLUG-python"
when: always
paths:
- .cache
before_script:
- !reference [.python-scripts]
- install_ca_certs "${CUSTOM_CA_CERTS:-$DEFAULT_CA_CERTS}"
- cd ${PYTHON_PROJECT_DIR}
- guess_build_system
.python-test:
extends: .python-base
stage: build
coverage: /^TOTAL.+?(\d+(?:\.\d+)?\%)$/
artifacts:
name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
expire_in: 1 day
when: always
reports:
junit:
- "$PYTHON_PROJECT_DIR/reports/TEST-*.xml"
coverage_report:
coverage_format: cobertura
path: "$PYTHON_PROJECT_DIR/reports/py-coverage.cobertura.xml"
paths:
- "$PYTHON_PROJECT_DIR/reports/TEST-*.xml"
- "$PYTHON_PROJECT_DIR/reports/py-coverage.*"
###############################################################################################
# build stage #
###############################################################################################
# build Python packages as artifacts
py-package:
extends: .python-base
stage: build
script:
- py_package
artifacts:
paths:
- $PYTHON_PROJECT_DIR/dist/*
expire_in: 1 day
rules:
- if: '$PYTHON_PACKAGE_ENABLED == "true"'
py-lint:
extends: .python-base
stage: build
script:
- mkdir -p -m 777 reports
- install_requirements
- _pip install pylint_gitlab # codeclimate reports
# run pylint and generate reports all at once
- _run pylint --output-format=colorized,pylint_gitlab.GitlabCodeClimateReporter:reports/py-lint.codeclimate.json,parseable:reports/py-lint.parseable.txt ${PYLINT_ARGS} ${PYLINT_FILES:-$(find -type f -name "*.py" -not -path "./.cache/*")}
artifacts:
name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
expire_in: 1 day
when: always
reports:
codequality: $PYTHON_PROJECT_DIR/reports/py-lint.codeclimate.json
paths:
- "$PYTHON_PROJECT_DIR/reports/py-lint.*"
rules:
# exclude if $PYLINT_ENABLED not set
- if: '$PYLINT_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
py-compile:
extends: .python-base
stage: build
script:
- install_requirements
- _python -m compileall $PYTHON_COMPILE_ARGS
rules:
# skip when one of unit test framework is enabled
- if: '$UNITTEST_ENABLED == "true" || $PYTEST_ENABLED == "true" || $NOSETESTS_ENABLED == "true"'
when: never
- !reference [.test-policy, rules]
py-black:
extends: .python-base
stage: build
script:
- install_requirements
- _pip install black
- _run black . --check --extend-exclude '(\/\.cache\/|\/\.venv\/)'
rules:
# exclude if $PYTHON_BLACK_ENABLED not set
- if: '$PYTHON_BLACK_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
py-isort:
extends: .python-base
stage: build
script:
- install_requirements
- _pip install isort
- _run isort . --check-only --extend-skip .cache --extend-skip .venv
rules:
# exclude if $PYTHON_ISORT_ENABLED not set
- if: '$PYTHON_ISORT_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
py-ruff:
extends: .python-base
stage: build
script:
- mkdir -p -m 777 reports
- |
if [[ ${BANDIT_ENABLED} == "true" || ${PYLINT_ENABLED} == "true" || ${PYTHON_ISORT_ENABLED} == "true" ]]; then
log_warn "Ruff can replace isort, Bandit, Pylint"
fi
# Ruff is self dependent tool (written in Rust), it can be installed without project dependencies (_pip and _run don't look required here)
- pip install ${PIP_OPTS} ruff
# JSON output (for SonarQube)
- |
if [[ "$SONAR_HOST_URL" ]]
then
ruff check . ${RUFF_ARGS} --extend-exclude .venv,.cache --exit-zero --output-format json --output-file reports/py-ruff.native.json
fi
# then GitLab and grouped/console formats
- ruff check . ${RUFF_ARGS} --extend-exclude .venv,.cache --output-format gitlab --output-file reports/py-ruff.gitlab.json || ruff check . ${RUFF_ARGS} --extend-exclude .venv,.cache --output-format grouped
artifacts:
name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
expire_in: 1 day
when: always
reports:
codequality: $PYTHON_PROJECT_DIR/reports/py-ruff.gitlab.json
paths:
- "$PYTHON_PROJECT_DIR/reports/py-ruff.*"
rules:
# exclude if $RUFF_ENABLED not set
- if: '$RUFF_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
py-ruff-format:
extends: .python-base
stage: build
script:
- |
if [[ ${PYTHON_BLACK_ENABLED} == "true" ]]; then
log_warn "Ruff can replace Black"
fi
# Ruff is self dependent tool (written in Rust), it can be installed without project dependencies (_pip and _run don't look required here)
- pip install ${PIP_OPTS} ruff
- ruff format --check . --exclude .venv,.cache
rules:
# exclude if $RUFF_FORMAT_ENABLED not set
- if: '$RUFF_FORMAT_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
py-mypy:
extends: .python-base
stage: build
variables:
MYPY_CACHE_DIR: "$CI_PROJECT_DIR/.cache/mypy"
script:
- mkdir -p -m 777 reports
- install_requirements
- _pip install mypy mypy-to-codeclimate
- _run mypy ${MYPY_ARGS} ${MYPY_FILES:-$(find -type f -name "*.py" -not -path "./.cache/*" -not -path "./.venv/*")} | tee reports/py-mypy.console.txt || true
# mypy-to-codeclimate will fail if any error was found
- _run mypy-to-codeclimate reports/py-mypy.console.txt reports/py-mypy.codeclimate.json
artifacts:
name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
expire_in: 1 day
when: always
reports:
codequality: $PYTHON_PROJECT_DIR/reports/py-mypy.codeclimate.json
paths:
- "$PYTHON_PROJECT_DIR/reports/py-mypy.*"
rules:
# exclude if $MYPY_ENABLED not set
- if: '$MYPY_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
###############################################################################################
# test stage #
###############################################################################################
py-unittest:
extends: .python-test
script:
- mkdir -p -m 777 reports
- install_requirements
# code coverage
- _pip install coverage
# JUnit XML report
- _pip install unittest-xml-reporting
- _run coverage run -m xmlrunner discover -o "reports/" $UNITTEST_ARGS
- _run coverage report -m
- _run coverage xml -o "reports/py-coverage.cobertura.xml"
rules:
# skip if $UNITTEST_ENABLED not set
- if: '$UNITTEST_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
py-pytest:
extends: .python-test
script:
- mkdir -p -m 777 reports
- install_requirements
- _pip install pytest pytest-cov coverage
- _python -m pytest --junit-xml=reports/TEST-pytests.xml --cov --cov-report term --cov-report xml:reports/py-coverage.cobertura.xml ${PYTEST_ARGS}
rules:
# skip if $PYTEST_ENABLED not set
- if: '$PYTEST_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
py-nosetests:
extends: .python-test
script:
- mkdir -p -m 777 reports
- install_requirements
- _run nosetests --with-xunit --xunit-file=reports/TEST-nosetests.xml --with-coverage --cover-erase --cover-xml --cover-xml-file=reports/py-coverage.cobertura.xml ${NOSETESTS_ARGS}
rules:
# skip if $NOSETESTS_ENABLED not set
- if: '$NOSETESTS_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
# Bandit (SAST)
py-bandit:
extends: .python-base
stage: test
# force no dependencies
dependencies: []
script:
- mkdir -p -m 777 reports
- install_requirements
- _pip install bandit
# CSV (for SonarQube)
- |
if [[ "$SONAR_HOST_URL" ]]
then
_run bandit ${TRACE+--verbose} --exit-zero --exclude ./.cache,./.venv --format csv --output reports/py-bandit.bandit.csv ${BANDIT_ARGS}
fi
# JSON (for DefectDojo)
- |
if [[ "$DEFECTDOJO_BANDIT_REPORTS" ]]
then
_run bandit ${TRACE+--verbose} --exit-zero --exclude ./.cache,./.venv --format json --output reports/py-bandit.bandit.json ${BANDIT_ARGS}
fi
- _run bandit ${TRACE+--verbose} --exclude ./.cache,./.venv ${BANDIT_ARGS}
artifacts:
when: always
name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
expire_in: 1 day
access: developer
paths:
- "$PYTHON_PROJECT_DIR/reports/py-bandit.*"
rules:
# exclude if $BANDIT_ENABLED not set
- if: '$BANDIT_ENABLED != "true"'
when: never
- !reference [.test-policy, rules]
# Trivy (dependency check)
py-trivy:
extends: .python-base
stage: test
# force no dependencies
dependencies: []
script:
- mkdir -p -m 777 reports
- |
if [[ -z "$PYTHON_TRIVY_DIST_URL" ]]
then
log_info "Trivy version unset: retrieve latest version..."
trivy_version=$(github_get_latest_version aquasecurity/trivy)
PYTHON_TRIVY_DIST_URL="https://github.com/aquasecurity/trivy/releases/download/${trivy_version}/trivy_${trivy_version:1}_Linux-64bit.tar.gz"
log_info "... use latest Trivy version: \\e[32m$PYTHON_TRIVY_DIST_URL\\e[0m"
fi
python_trivy="$XDG_CACHE_HOME/trivy-$(echo "$PYTHON_TRIVY_DIST_URL" | md5sum | cut -d" " -f1)"
if [[ -f $python_trivy ]]
then
log_info "Trivy found in cache (\\e[32m$PYTHON_TRIVY_DIST_URL\\e[0m): reuse"
else
log_info "Trivy not found in cache (\\e[32m$PYTHON_TRIVY_DIST_URL\\e[0m): download"
python3 -c 'import urllib.request;urllib.request.urlretrieve("'$PYTHON_TRIVY_DIST_URL'","trivy.tar.gz")'
tar zxf trivy.tar.gz trivy
mkdir -p $XDG_CACHE_HOME
mv ./trivy $python_trivy
fi
- |
case "$PYTHON_BUILD_SYSTEM" in
poetry*|pipenv*)
log_info "$PYTHON_BUILD_SYSTEM build system (\\e[32muse lock file\\e[0m)"
cp poetry.lock Pipfile.lock ./reports 2>/dev/null || true
;;
uv*)
log_info "$PYTHON_BUILD_SYSTEM build system used (\\e[32mmust generate pinned requirements.txt from uv.lock\\e[0m)"
maybe_install_uv
uv export > ./reports/requirements.txt
;;
*)
log_info "$PYTHON_BUILD_SYSTEM build system used (\\e[32mmust generate pinned requirements.txt\\e[0m)"
install_requirements
_pip freeze | tee ./reports/requirements.txt
;;
esac
if [[ -f "./requirements.txt" ]]
then
sort -u ./requirements.txt | grep -v "^[ ]*$" > ./requirements.txt.sorted
sort -u ./reports/requirements.txt | grep -v "^[ ]*$" > ./requirements.txt.generated.sorted
if [[ $(diff ./requirements.txt.sorted ./requirements.txt.generated.sorted) ]]
then
log_warn "The ./requirements.txt file does not match the ./reports/requirements.txt file generated via pip freeze. Make sure to include all dependencies with pinned versions in ./requirements.txt and re-commit the file."
fi
fi
# Generate the native JSON report that can later be converted to other formats
$python_trivy fs ${PYTHON_TRIVY_ARGS} --format json --list-all-pkgs --output reports/py-trivy.trivy.json --exit-code 1 ./reports/ > ./reports/trivy.log 2>&1 || exit_code=$?
cat ./reports/trivy.log
if [ $(grep -ic "Number of language-specific files[^0-9]*0$" ./reports/trivy.log) -eq 1 ]; then
log_error "Could not find a file listing all dependencies with their versions."
exit 1
fi
rm ./reports/trivy.log
# console output
$python_trivy convert --format table reports/py-trivy.trivy.json
exit $exit_code
artifacts:
name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
expire_in: 1 day
access: developer
when: always
paths:
- "$PYTHON_PROJECT_DIR/reports/py-trivy.*"
- "$PYTHON_PROJECT_DIR/reports/requirements.txt"
rules:
# exclude if $PYTHON_TRIVY_ENABLED not set
- if: '$PYTHON_TRIVY_DISABLED == "true"'
when: never
- !reference [.test-policy, rules]
py-sbom:
extends: .python-base
stage: test
# force no dependency
dependencies: []
needs: []
script:
- mkdir -p -m 777 reports
- |
case "$PYTHON_BUILD_SYSTEM" in
poetry*|pipenv*)
log_info "$PYTHON_BUILD_SYSTEM build system (\\e[32muse lock file\\e[0m)"
;;
uv*)
log_info "$PYTHON_BUILD_SYSTEM build system used (\\e[32mmust generate pinned requirements.txt from uv.lock\\e[0m)"
maybe_install_uv
uv export > ./reports/requirements.txt
;;
*)
log_info "$PYTHON_BUILD_SYSTEM build system used (\\e[32mmust generate pinned requirements.txt\\e[0m)"
install_requirements
_pip freeze > "${PYTHON_REQS_FILE}"
;;
esac
- |
if [[ -z "$PYTHON_SBOM_SYFT_URL" ]]
then
log_info "Syft version unset: retrieve latest version..."
syft_version=$(github_get_latest_version anchore/syft)
PYTHON_SBOM_SYFT_URL="https://github.com/anchore/syft/releases/download/${syft_version}/syft_${syft_version:1}_linux_amd64.tar.gz"
log_info "... use latest Syft version: \\e[32m$PYTHON_SBOM_SYFT_URL\\e[0m"
fi
python_sbom_syft="$PIP_CACHE_DIR/syft-$(echo "$PYTHON_SBOM_SYFT_URL" | md5sum | cut -d" " -f1)"
if [ ! -f $python_sbom_syft ]; then
python3 -c 'import urllib.request;urllib.request.urlretrieve("'$PYTHON_SBOM_SYFT_URL'","syft.tar.gz")'
tar zxf syft.tar.gz syft
mkdir -p $PIP_CACHE_DIR
mv ./syft $python_sbom_syft
fi
- $python_sbom_syft dir:. --source-name $PYTHON_SBOM_NAME $PYTHON_SBOM_OPTS -o cyclonedx-json > reports/py-sbom.cyclonedx.json
- chmod a+r reports/py-sbom.cyclonedx.json
artifacts:
name: "Python SBOM from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
expire_in: 1 week
when: always
paths:
- "$PYTHON_PROJECT_DIR/reports/py-sbom.cyclonedx.json"
reports:
cyclonedx:
- "$PYTHON_PROJECT_DIR/reports/py-sbom.cyclonedx.json"
rules:
# exclude if disabled
- if: '$PYTHON_SBOM_DISABLED == "true"'
when: never
# 'always' mode: run
- if: '$TBC_SBOM_MODE == "always"'
# exclude unsupported modes
- if: '$TBC_SBOM_MODE != "onrelease"'
when: never
# 'onrelease' mode: use common software delivery rules
- !reference [.delivery-policy, rules]
# (manual from master branch): triggers a release (tag creation)
py-release:
extends: .python-base
stage: publish
before_script:
- !reference [.python-base, before_script]
# install git and OpenSSH
- maybe_install_packages git openssh-client
script:
- git config --global user.email "$GITLAB_USER_EMAIL"
- git config --global user.name "$GITLAB_USER_LOGIN"
- git checkout -B $CI_COMMIT_REF_NAME
- configure_scm_auth
- py_release
artifacts:
paths:
- $PYTHON_PROJECT_DIR/dist/*
rules:
# exclude if $PYTHON_RELEASE_ENABLED not set
- if: '$PYTHON_RELEASE_ENABLED != "true"'
when: never
# on production branch: auto if $PYTHON_AUTO_RELEASE_ENABLED set and implicitly $PYTHON_RELEASE_ENABLED set
- if: '$PYTHON_AUTO_RELEASE_ENABLED == "true" && $CI_COMMIT_REF_NAME =~ $PROD_REF'
# on production or integration branch: manual, non blocking
- if: '$CI_COMMIT_REF_NAME =~ $PROD_REF || $CI_COMMIT_REF_NAME =~ $INTEG_REF'
when: manual
allow_failure: true
# (auto from release tag): publishes the Python package(s) to a PyPi registry
py-publish:
extends: .python-base
stage: publish
before_script:
- !reference [.python-base, before_script]
# install curl (to decode @url@ variables)
- maybe_install_packages curl
script:
- py_publish
artifacts:
paths:
- $PYTHON_PROJECT_DIR/dist/*
rules:
# exclude if $PYTHON_RELEASE_ENABLED not set
- if: '$PYTHON_RELEASE_ENABLED != "true" && $PYTHON_PUBLISH_ENABLED != "true"'
when: never
# on tag with release pattern: auto
- if: '$CI_COMMIT_TAG =~ $RELEASE_REF'