diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 80ad328f094433833f53a5512cfc85f93eb799a5..36dd190f15721ddd0dc5c8cbdf0eba75ab7902c9 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,4 +1,245 @@
-include:
-  - project: bob/citools
-    ref: master
-    file: /src/citools/data/python.yml
+workflow:
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
+    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
+    - if: $CI_COMMIT_TAG
+
+variables:
+  PYTHONUNBUFFERED: "1"
+  XDG_CACHE_HOME: "${CI_PROJECT_DIR}/cache"
+  CITOOLS: "git+https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.idiap.ch/bob/citools"
+
+default:
+  tags:
+    - bob
+    - docker
+  image: python:3.10
+
+stages:
+  - qa
+  - test
+  - doc
+  - dist
+  - deploy
+
+quality:
+  stage: qa
+  before_script:
+    - python3 -m venv venv
+    - source venv/bin/activate
+    - pip install pre-commit
+  script:
+    - pre-commit run --all-files --show-diff-on-failure --verbose
+  needs: []
+  cache:
+    key: pre-commit-cache
+    paths:
+      - ${XDG_CACHE_HOME}/pre-commit
+      - ${XDG_CACHE_HOME}/pip
+  rules:
+    - exists:
+      - .pre-commit-config.yaml
+
+tests:
+  stage: test
+  image: python:${PYTHON}
+  parallel:
+    matrix:
+      - PYTHON: ["3.9", "3.10"]
+        TAG: ["docker"]
+  tags:
+    - bob
+    - ${TAG}
+  needs:
+    - job: quality
+      optional: true
+  before_script:
+    # get pip-constraints.txt from distribution
+    - python3 -m venv _citools
+    - source _citools/bin/activate
+    - pip install "${CITOOLS}"
+    - citool pip-constraints "constraints.txt"
+    - deactivate
+    # normal test procedure
+    - python3 -m venv venv
+    - source venv/bin/activate
+    - pip install -c constraints.txt '.[test]' pytest-cov coverage
+  script:
+    - pytest -sv --cov-report "html:html/coverage" --cov-report "xml:coverage.xml" --junitxml "junit-coverage.xml"
+  coverage: '/(?:TOTAL|total|Total).*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
+  cache:
+    key: test-cache-${TAG}-${PYTHON}
+    paths:
+      - ${XDG_CACHE_HOME}/pip
+  artifacts:
+    paths:
+      - html
+    reports:
+      junit: junit-coverage.xml
+      coverage_report:
+        coverage_format: cobertura
+        path: coverage.xml
+
+documentation:
+  stage: doc
+  rules:
+    - exists:
+      - doc/conf.py
+  needs:
+    - job: quality
+      optional: true
+  cache:
+    key: doc-cache
+    paths:
+      - ${XDG_CACHE_HOME}/pip
+  before_script:
+    # get pip-constraints.txt from distribution
+    - python3 -m venv _citools
+    - source _citools/bin/activate
+    - pip install "${CITOOLS}"
+    - citool pip-constraints "constraints.txt"
+    - deactivate
+    # normal doc-building procedure
+    - python3 -m venv venv
+    - source venv/bin/activate
+    - pip install -c constraints.txt '.[doc]' sphinx
+  script:
+    - sphinx-build -aEW doc html/sphinx
+    - sphinx-build -aEb doctest doc html/doctest
+  artifacts:
+    paths:
+      - html
+
+python-package:
+  stage: dist
+  cache:
+    key: packaging-cache
+    paths:
+      - ${XDG_CACHE_HOME}/pip
+  needs:
+    - tests
+  before_script:
+    - python3 -m venv venv
+    - source venv/bin/activate
+    - pip install build twine
+  script:
+    - python -m build
+    - twine check dist/*
+  artifacts:
+    paths:
+      - dist
+
+conda-package:
+  stage: dist
+  # make sure we use the same image as conda-forge:
+  # https://github.com/conda-forge/conda-forge-pinning-feedstock/blob/main/recipe/conda_build_config.yaml
+  image: quay.io/condaforge/linux-anvil-cos7-x86_64
+  variables:
+    CONDA_OVERRIDE_CUDA: "11.6"
+  needs:
+    - tests
+  rules:
+    - exists:
+      - conda/meta.yaml
+  before_script:
+    - pip install "${CITOOLS}"
+    - rm -f /home/conda/.condarc
+    - citool conda-config --group "${CI_PROJECT_NAMESPACE}" --visibility "${CI_PROJECT_VISIBILITY}" --tag "${CI_COMMIT_TAG}" /opt/conda/condarc
+    - mamba update -n base --all
+    - eval "$(/opt/conda/bin/conda shell.bash hook)"
+    - PACKAGE_NAME=$(python setup.py --name 2>/dev/null)
+    - PACKAGE_VERSION=$(python setup.py --version 2>/dev/null)
+    - export BOB_BUILD_NUMBER=$(citool next-build --group "${CI_PROJECT_NAMESPACE}" --visibility "${CI_PROJECT_VISIBILITY}" --tag "${CI_COMMIT_TAG}" --name "${PACKAGE_NAME}" --version "${PACKAGE_VERSION}" conda-package-matches.txt)
+  script:
+    - mkdir dist
+    - echo "-> ${PACKAGE_NAME}-${PACKAGE_VERSION} build-number is ${BOB_BUILD_NUMBER}"
+    - citool conda-constraints conda/conda_build_config.yaml conda/recipe_append.yaml
+    - conda info
+    - conda mambabuild --output-folder=$(pwd)/dist conda
+  artifacts:
+    paths:
+      - dist
+      - conda-package-matches.txt
+
+local-pypi:
+  stage: deploy
+  needs:
+    - python-package
+  cache:
+    key: pypi-cache
+    paths:
+      - ${XDG_CACHE_HOME}/pip
+  rules:
+    # conditions:
+    # - only on the main branch
+    # - public or private
+    # - for tags or not
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+  variables:
+    TWINE_USERNAME: "gitlab-ci-token"
+    TWINE_PASSWORD: "${CI_JOB_TOKEN}"
+  before_script:
+    - python3 -m venv venv
+    - source venv/bin/activate
+    - pip install "${CITOOLS}" twine
+    - PACKAGE_NAME=$(python setup.py --name 2>/dev/null)
+    - PACKAGE_VERSION=$(python setup.py --version 2>/dev/null)
+  script:
+    # only accept to replace version if not a tag
+    - test -z "${CI_COMMIT_TAG}" && citool deregister --git-url "${CI_SERVER_URL}" --token "${PYPI_PACKAGE_REGISTRY_TOKEN}" --project "${CI_PROJECT_ID}" --name "${PACKAGE_NAME}" --version "${PACKAGE_VERSION}"
+    - twine upload --repository-url "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi" dist/*
+
+pypi:
+  extends: local-pypi
+  needs:
+    # only uploads to official PyPI if local PyPI upload suceeded as well
+    - local-pypi
+  rules:
+    # conditions:
+    # - only on the main branch
+    # - only for public packages
+    # - only for tags in PEP-440 style
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PROJECT_VISIBILITY == "public" && $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+([abc]\d*)?$/
+  variables:
+    TWINE_USERNAME: "${PYPIUSER}"
+    TWINE_PASSWORD: "${PYPIPASS}"
+  script:
+    - twine upload dist/*
+
+local-docs:
+  stage: deploy
+  needs:
+    - tests
+    - job: documentation
+      optional: true
+  rules:
+    - if: ($CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH)
+  cache:
+    key: deploy-docs-cache
+    paths:
+      - ${XDG_CACHE_HOME}/pip
+  before_script:
+    - python3 -m venv venv
+    - source venv/bin/activate
+    - pip install "${CITOOLS}"
+  script:
+    # uses DOCUSER/DOCPASS environment variables
+    - citool deploy-docs --package "${CI_PROJECT_PATH}" --visibility "${CI_PROJECT_VISIBILITY}" --branch "${CI_COMMIT_REF_NAME}" --tag "${CI_COMMIT_TAG}" html
+
+conda-deploy:
+  stage: deploy
+  needs:
+    - conda-package
+  rules:
+    - if: ($CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH)
+  cache:
+    key: deploy-conda-cache
+    paths:
+      - ${XDG_CACHE_HOME}/pip
+  before_script:
+    - python3 -m venv venv
+    - source venv/bin/activate
+    - pip install "${CITOOLS}"
+  script:
+    # uses DOCUSER/DOCPASS environment variables
+    - citool deploy-conda --visibility "${CI_PROJECT_VISIBILITY}" --tag "${CI_COMMIT_TAG}" dist/*/*.conda