diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 3df0dfce8ee2abecabf596a6c3243119190212e6..efbaa2252ab033d9979b43b1914119c7d1fffd35 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -5,6 +5,8 @@ include:
   - local: templates/Rules.yaml
   - local: templates/Runners/eole-docker.yaml
   - local: templates/Lint/Commitlint.yaml
+  - local: templates/Release/Semantic-release.yaml
 
 stages:
   - lint
+  - release
diff --git a/release-rules.js b/release-rules.js
new file mode 100644
index 0000000000000000000000000000000000000000..c63c850df486dffd88619df1618385ed3c5eef1c
--- /dev/null
+++ b/release-rules.js
@@ -0,0 +1,18 @@
+// No release is triggered for the types commented out below.
+// Commits using these types will be incorporated into the next release.
+//
+// NOTE: Any changes here must be reflected in `CONTRIBUTING.md`.
+module.exports = [
+  {breaking: true, release: 'major'},
+  // {type: 'build', release: 'patch'},
+  // {type: 'chore', release: 'patch'},
+  // {type: 'ci', release: 'patch'},
+  {type: 'docs', release: 'patch'},
+  {type: 'feat', release: 'minor'},
+  {type: 'fix', release: 'patch'},
+  {type: 'perf', release: 'patch'},
+  {type: 'refactor', release: 'patch'},
+  {type: 'revert', release: 'patch'},
+  {type: 'style', release: 'patch'},
+  {type: 'test', release: 'patch'},
+];
diff --git a/release.config.js b/release.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..d749cd2fd5fbc384cee8fdf0aeb10e02093b08d4
--- /dev/null
+++ b/release.config.js
@@ -0,0 +1,105 @@
+module.exports = {
+  branches: 'stable',
+  tagFormat: 'release/${version}',
+  plugins: [
+      ['@semantic-release/commit-analyzer', {
+        preset: 'angular',
+        releaseRules: './release-rules.js',
+      }],
+      '@semantic-release/release-notes-generator',
+      ['@semantic-release/changelog', {
+        changelogFile: 'docs/CHANGELOG.md',
+        changelogTitle: '# Changelog',
+      }],
+      ['@semantic-release/git', {
+        assets: ['docs'],
+        message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}'
+      }],
+      '@semantic-release/gitlab',
+  ],
+  generateNotes: {
+    preset: 'angular',
+    writerOpts: {
+      // Required due to upstream bug preventing all types being displayed.
+      // Bug: https://github.com/conventional-changelog/conventional-changelog/issues/317
+      // Fix: https://github.com/conventional-changelog/conventional-changelog/pull/410
+      transform: (commit, context) => {
+          const issues = []
+
+          commit.notes.forEach(note => {
+              note.title = `BREAKING CHANGES`
+          })
+
+          // NOTE: Any changes here must be reflected in `CONTRIBUTING.md`.
+          if (commit.type === `feat`) {
+              commit.type = `Features`
+          } else if (commit.type === `fix`) {
+              commit.type = `Bug Fixes`
+          } else if (commit.type === `perf`) {
+              commit.type = `Performance Improvements`
+          } else if (commit.type === `revert`) {
+              commit.type = `Reverts`
+          } else if (commit.type === `docs`) {
+              commit.type = `Documentation`
+          } else if (commit.type === `style`) {
+              commit.type = `Styles`
+          } else if (commit.type === `refactor`) {
+              commit.type = `Code Refactoring`
+          } else if (commit.type === `test`) {
+              commit.type = `Tests`
+          } else if (commit.type === `build`) {
+              commit.type = `Build System`
+          // } else if (commit.type === `chore`) {
+          //     commit.type = `Maintenance`
+          } else if (commit.type === `ci`) {
+              commit.type = `Continuous Integration`
+          } else {
+              return
+          }
+
+          if (commit.scope === `*`) {
+              commit.scope = ``
+          }
+
+          if (typeof commit.hash === `string`) {
+              commit.shortHash = commit.hash.substring(0, 7)
+          }
+
+          if (typeof commit.subject === `string`) {
+              let url = context.repository
+                  ? `${context.host}/${context.owner}/${context.repository}`
+                  : context.repoUrl
+              if (url) {
+                  url = `${url}/issues/`
+                  // Issue URLs.
+                  commit.subject = commit.subject.replace(/#([0-9]+)/g, (_, issue) => {
+                      issues.push(issue)
+                      return `[#${issue}](${url}${issue})`
+                  })
+              }
+              if (context.host) {
+                  // User URLs.
+                  commit.subject = commit.subject.replace(/\B@([a-z0-9](?:-?[a-z0-9/]){0,38})/g, (_, username) => {
+                  if (username.includes('/')) {
+                      return `@${username}`
+                  }
+
+                  return `[@${username}](${context.host}/${username})`
+                  })
+              }
+          }
+
+          // remove references that already appear in the subject
+          commit.references = commit.references.filter(reference => {
+              if (issues.indexOf(reference.issue) === -1) {
+                  return true
+              }
+
+              return false
+          })
+
+          return commit
+      },
+    },
+  },
+};
diff --git a/templates/Release/Semantic-release.yaml b/templates/Release/Semantic-release.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..eb9ccf9709035cd65110f1225033a728d42e6834
--- /dev/null
+++ b/templates/Release/Semantic-release.yaml
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+# vim: ft=yaml
+---
+# Produce a new release using semantic versionning scheme when new
+# commits arrive on the production branch.
+#
+# USAGE
+# =====
+#
+# include:
+#   - project: EOLE/infra/ci-tools
+#     ref: stable
+#     file: /templates/Release/Semantic-release.yaml
+#
+# REQUIREMENTS
+# ============
+#
+# - A `release` stage must be present in your pipeline
+# - One of the `semantic-release` configuration file must be present
+#   - `release.config.js`
+#   - `.releaserc`
+#   - `.releaserc.js`
+#   - `.releaserc.json`
+#   - `.releaserc.yaml`
+#   - `.releaserc.yml`
+# - an access token named `GITLAB_TOKEN` with
+#   - `api`
+#   - `read_repository`
+#   - `write_repository`
+# - The variable `$STABLE_BRANCH` defined
+#
+# OPTIONAL VARIABLES
+# ==================
+#
+# - `SEMANTIC_RELEASE_IMAGE`: name of the `semantic-release` docker
+#   image to use
+#
+# SEE ALSO
+# ========
+#
+# - Semantic release software: https://github.com/semantic-release/semantic-release/
+# - Commitlint: https://github.com/conventional-changelog/commitlint/
+#
+# IMPORTANT NOTE
+# ==============
+#
+# We can't merge rules with `!reference` until we switch to Gitlab >= 14.3
+# https://gitlab.com/gitlab-org/gitlab/-/issues/322992
+
+.on-stable-with-semantic-release-config:
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "schedule"'
+      when: never
+    # Exclude semantic-release commits on $STABLE_BRANCH
+    - if: $CI_COMMIT_BRANCH == $STABLE_BRANCH && $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
+      when: never
+    - if: $CI_COMMIT_BRANCH == $STABLE_BRANCH
+      exists:
+        - release.config.js
+        - .releaserc
+        - .releaserc.yaml
+        - .releaserc.yml
+        - .releaserc.json
+        - .releaserc.js
+      when: on_success
+
+semantic-release:
+  stage: release
+  extends: .on-stable-with-semantic-release-config
+  image: "$SEMANTIC_RELEASE_IMAGE"
+  variables:
+    SEMANTIC_RELEASE_IMAGE: 'hub.eole.education/eole/semantic-release:latest'
+  script:
+    - 'semantic-release'
+...