diff --git a/bob/devtools/build.py b/bob/devtools/build.py
index 70ee86acf8f27a4984f9b7e132a471ee749b318c..59aa728f96ae6fb123e5f938fc419e9c7fa038d5 100644
--- a/bob/devtools/build.py
+++ b/bob/devtools/build.py
@@ -534,6 +534,7 @@ def git_clean_build(runner, verbose):
     exclude_from_cleanup = [
         "miniconda.sh",  # the installer, cached
         "sphinx",  # build artifact -- documentation
+        "coverage.xml",  # build artifact -- coverage report
     ]
 
     # artifacts
diff --git a/bob/devtools/scripts/badges.py b/bob/devtools/scripts/badges.py
new file mode 100644
index 0000000000000000000000000000000000000000..19f89c031e798ad30a53fcfda2122c473cb5fe7c
--- /dev/null
+++ b/bob/devtools/scripts/badges.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python
+
+import os
+
+import click
+import gitlab
+
+from . import bdt
+from ..release import get_gitlab_instance, update_files_at_master
+
+from ..log import verbosity_option, get_logger, echo_normal, echo_warning
+
+logger = get_logger(__name__)
+
+
+# These show on the gitlab project landing page (not visible on PyPI)
+PROJECT_BADGES = [
+    {
+        "name": "Docs (stable)",
+        "link_url": "https://www.idiap.ch/software/{group}/docs/%{{project_path}}/stable/index.html",
+        "image_url": "https://img.shields.io/badge/docs-stable-yellow.svg",
+    },
+    {
+        "name": "Docs (latest)",
+        "link_url": "https://www.idiap.ch/software/{group}/docs/%{{project_path}}/%{{default_branch}}/index.html",
+        "image_url": "https://img.shields.io/badge/docs-latest-orange.svg",
+    },
+    {
+        "name": "Pipeline (status)",
+        "link_url": "https://gitlab.idiap.ch/%{{project_path}}/commits/%{{default_branch}}",
+        "image_url": "https://gitlab.idiap.ch/%{{project_path}}/badges/%{{default_branch}}/pipeline.svg",
+    },
+    {
+        "name": "Coverage (latest)",
+        "link_url": "https://gitlab.idiap.ch/%{{project_path}}/commits/%{{default_branch}}",
+        "image_url": "https://gitlab.idiap.ch/%{{project_path}}/badges/%{{default_branch}}/coverage.svg",
+    },
+    {
+        "name": "PyPI (version)",
+        "link_url": "https://pypi.python.org/pypi/{name}",
+        "image_url": "https://img.shields.io/pypi/v/{name}.svg",
+    },
+]
+
+
+# These show on the README and will be visible in PyPI
+README_BADGES = [
+    {
+        "name": "Docs (current)",
+        "link_url": "https://www.idiap.ch/software/{group}/docs/{group}/{name}/master/index.html",
+        "image_url": "https://img.shields.io/badge/docs-available-orage.svg",
+    },
+    {
+        "name": "Pipeline (current)",
+        "link_url": "https://gitlab.idiap.ch/{group}/{name}/commits/master",
+        "image_url": "https://gitlab.idiap.ch/{group}/{name}/badges/master/pipeline.svg",
+    },
+    {
+        "name": "Coverage (current)",
+        "link_url": "https://gitlab.idiap.ch/{group}/{name}/commits/master",
+        "image_url": "https://gitlab.idiap.ch/{group}/{name}/badges/master/coverage.svg",
+    },
+    {
+        "name": "Gitlab project",
+        "link_url": "https://gitlab.idiap.ch/{group}/{name}",
+        "image_url": "https://img.shields.io/badge/gitlab-project-0000c0.svg",
+    },
+]
+
+
+def _update_readme(content, info):
+    """Updates the README content provided, replacing badges"""
+
+    import re
+
+    new_badges_text = []
+    for badge in README_BADGES:
+        data = dict((k, v.format(**info)) for (k,v) in badge.items())
+        new_badges_text.append(".. image:: {image_url}".format(**data))
+        new_badges_text.append("   :target: {link_url}".format(**data))
+    new_badges_text = '\n'.join(new_badges_text) + '\n'
+    # matches only 3 or more occurences of ..image::/:target: occurences
+    expression = r"(\.\.\s*image.+\n\s+:target:\s*.+\b\n){3,}"
+    return re.sub(expression, new_badges_text, content)
+
+
+@click.command(
+    epilog="""
+Examples:
+
+  1. Creates (by replacing) all existing badges in a gitlab project
+     (bob/bob.devtools):
+
+     $ bdt gitlab badges bob/bob.devtools
+
+     N.B.: This command also affects the README.rst file.
+
+"""
+)
+@click.argument("package")
+@click.option(
+    "-d",
+    "--dry-run/--no-dry-run",
+    default=False,
+    help="Only goes through the actions, but does not execute them "
+    "(combine with the verbosity flags - e.g. ``-vvv``) to enable "
+    "printing to help you understand what will be done",
+)
+@verbosity_option()
+@bdt.raise_on_error
+def badges(package, dry_run):
+    """Creates stock badges for a project repository"""
+
+    # if we are in a dry-run mode, let's let it be known
+    if dry_run:
+        logger.warn("!!!! DRY RUN MODE !!!!")
+        logger.warn("Nothing is being changed at Gitlab")
+
+    if "/" not in package:
+        raise RuntimeError('PACKAGE should be specified as "group/name"')
+
+    gl = get_gitlab_instance()
+
+    # we lookup the gitlab package once
+    try:
+        use_package = gl.projects.get(package)
+        logger.info(
+            "Found gitlab project %s (id=%d)",
+            use_package.attributes["path_with_namespace"],
+            use_package.id,
+        )
+
+        badges = use_package.badges.list()
+        for badge in badges:
+            logger.info(
+                "Removing badge '%s' (id=%d) => '%s'",
+                badge.name,
+                badge.id,
+                badge.link_url,
+            )
+            if not dry_run: badge.delete()
+
+        # creates all stock badges, preserve positions
+        info = dict(zip(("group", "name"), package.split("/", 1)))
+        for position, badge in enumerate(PROJECT_BADGES):
+            data = dict([(k,v.format(**info)) for (k,v) in badge.items()])
+            data["position"] = position
+            logger.info(
+                "Creating badge '%s' => '%s'",
+                data["name"],
+                data["link_url"],
+            )
+            if not dry_run: use_package.badges.create(data)
+
+        # download and edit README to setup badges
+        readme_file = use_package.files.get(file_path="README.rst", ref="master")
+        readme_content = readme_file.decode().decode()
+        readme_content = _update_readme(readme_content, info)
+        # commit and push changes
+        logger.info("Changing README.rst badges...")
+        update_files_at_master(use_package, {"README.rst": readme_content},
+            "Updated badges section [ci skip]", dry_run)
+        logger.info("All done.")
+
+    except gitlab.GitlabGetError as e:
+        logger.warn("Gitlab access error - package %s does not exist?", package)
+        echo_warning("%s: unknown" % (package,))
diff --git a/conda/meta.yaml b/conda/meta.yaml
index 838772de640254d1c2241400fb250a8ebc3aaad2..a41ac8893c86bacf1701ce5386d6b4bae861dfb2 100644
--- a/conda/meta.yaml
+++ b/conda/meta.yaml
@@ -106,6 +106,7 @@ test:
     - bdt gitlab process-pipelines --help
     - bdt gitlab get-pipelines --help
     - bdt gitlab graph --help
+    - bdt gitlab badges --help
     - sphinx-build -aEW ${PREFIX}/share/doc/{{ name }}/doc sphinx
     - if [ -n "${CI_PROJECT_DIR}" ]; then mv sphinx "${CI_PROJECT_DIR}/"; fi
 
diff --git a/setup.py b/setup.py
index 449ba6d330f33c97c335a99c42987d819bc7cce5..f4cb86eb71c40be0478188dc21502ddd2930b724 100644
--- a/setup.py
+++ b/setup.py
@@ -59,6 +59,7 @@ setup(
           ],
 
         'bdt.gitlab.cli': [
+          'badges = bob.devtools.scripts.badges:badges',
           'commitfile = bob.devtools.scripts.commitfile:commitfile',
           'release = bob.devtools.scripts.release:release',
           'changelog = bob.devtools.scripts.changelog:changelog',
@@ -68,7 +69,7 @@ setup(
           'visibility = bob.devtools.scripts.visibility:visibility',
           'getpath = bob.devtools.scripts.getpath:getpath',
           'process-pipelines = bob.devtools.scripts.pipelines:process_pipelines',
-          'get-pipelines- = bob.devtools.scripts.pipelines:get_pipelines',
+          'get-pipelines = bob.devtools.scripts.pipelines:get_pipelines',
           'graph = bob.devtools.scripts.graph:graph',
           'update-bob = bob.devtools.scripts.update_bob:update_bob',
           ],