diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6cc0c3f86bddb0814db207b1bf1ffb40093fa744..617ca6f97f19823e80cb532e069efacce303f76c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,36 +1,49 @@
 ---
 stages:
-  - version
   - prepare
   - build
   - collect
   - deploy
 
 
-version:
-  image: python:3.7-stretch
-  stage: version
-  script:
-    - python3 scripts/update-version.py -k "$SSH_TAGGING_KEY" -o version -v
-  artifacts:
-    paths:
-      - version
-  only:
-    - branches
+# Anchors
 
+.release_rules: &release_rules
+  rules:
+    - if: "$CI_COMMIT_TAG =~ /^release-/ && $CI_COMMIT_BRANCH == 'master'"
+      when: always
+    - when: never
 
-prepare:
+
+
+# Stage: prepare
+#
+# Builds unpacked extensions from the source files.
+
+.prepare:
   stage: prepare
   image: python:3.7-stretch
   script:
+    - python3 scripts/update-version.py -k "$SSH_TAGGING_KEY" -o version -v
     - bash scripts/build.sh --version "$(cat version)"
   artifacts:
     paths:
+      - version
       - build/**
+
+prepare:beta:
+  extends: .prepare
   only:
-    - master
     - beta
 
+prepare:release:
+  extends: .prepare
+  <<: *release_rules
+
+
+# Stage: build
+#
+# Builds packages from the prepared unpacked extensions.
 
 .build:firefox:
   stage: build
@@ -44,16 +57,16 @@ prepare:
     - npm install --global web-ext --cache "$CI_PROJECT_DIR/.cache/npm" --prefer-offline --no-audit
     - cd build/firefox
     - web-ext -a . sign --channel=$CHANNEL
-    - mv atp_safe_links_cleaner* ../../safelinks-cleaner-firefox.xpi
+    - mv atp_safe_links_cleaner* ../../safelinks-cleaner-firefox-$VARIANT.xpi
   artifacts:
     paths:
       - safelinks-cleaner-firefox.xpi
 
-
 build:firefox:beta:
   extends: .build:firefox
   variables:
     CHANNEL: unlisted
+    VARIANT: beta
   rules:
     - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "beta"'
       changes:
@@ -64,34 +77,30 @@ build:firefox:beta:
       when: always
     - when: never
 
-
-
-build:firefox:master:
+build:firefox:release:
   extends: .build:firefox
   variables:
     CHANNEL: listed
-  rules:
-    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master"'
-      changes:
-        - shared/**
-        - firefox/**
-      when: always
-    - if: '$CI_COMMIT_MESSAGE =~ /#force-build:(all|firefox)/ && $CI_COMMIT_BRANCH == "master"'
-      when: always
-    - when: never
+    VARIANT: release
+  <<: *release_rules
 
 
-build:thunderbird:beta:
+.build:thunderbird:
   stage: build
   image: python:3.7-stretch
   script:
     - apt-get -y update
     - apt-get -y install zip
     - cd build/thunderbird
-    - zip -r ../../safelinks-cleaner-thunderbird.xpi *
+    - zip -r ../../safelinks-cleaner-thunderbird-$VARIANT.xpi *
   artifacts:
     paths:
-      - safelinks-cleaner-thunderbird.xpi
+      - safelinks-cleaner-thunderbird-$VARIANT.xpi
+
+.build:thunderbird:beta:
+  extends: .build:thunderbird
+  variables:
+    VARIANT: beta
   rules:
     - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "beta"'
       changes:
@@ -102,23 +111,30 @@ build:thunderbird:beta:
       when: always
     - when: never
 
+.build:thunderbird:release:
+  extends: .build:thunderbird
+  variables:
+    VARIANT: release
+  <<: *release_rules
+
+
+# Stage: collect/deploy
+#
+# Wait for build artifacts then deploy new beta version to pages.
 
 collect:
   stage: collect
   script:
     - echo "done"
   only:
-    - master
     - beta
 
-
 pages:
   stage: deploy
   script:
     - cp -r site .public
     - sed -i -e "s/%BUILDDATE%/$(date +'%Y-%m-%d %H:%M')/g" .public/index.html
-    - cp safelinks-cleaner-thunderbird.xpi .public
-    - cp safelinks-cleaner-firefox.xpi .public
+    - cp safelinks-cleaner-*.xpi .public
     - mv .public public
     - ls -lr public
   needs:
@@ -131,9 +147,8 @@ pages:
       job: build:firefox:beta
       ref: $CI_COMMIT_REF_NAME
       artifacts: true
+  only:
+    - beta
   artifacts:
     paths:
       - public/
-  only:
-    - beta
-  when: always