diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 2270db196b8d7c23e19350e72433967f6d7aa5bf..826cd080ab3f0113351e5c8bea8d09a3ffb229f8 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,6 @@
 stages:
-  - pypi
+  - prepare-release
+  - release
 
 workflow:
   rules:
@@ -7,13 +8,71 @@ workflow:
     - when: never
 
 pypi:
-  stage: pypi
+  stage: release
   image:
-    name: python:3.11-alpine3.18
+    name: python:alpine
   before_script:
     - pip install twine build
   script:
     - python -m build
     - twine upload -u __token__ -p ${PYPI_JUPYTERJSC_TOKEN} dist/*
   rules:
-    - if: $CI_COMMIT_TAG
+    - if: '$CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/'
+
+testpypi:
+  stage: release
+  image:
+    name: python:alpine
+  before_script:
+    - pip install twine build
+  script:
+    - python -m build
+    - twine upload --repository testpypi -u __token__ -p ${TESTPYPI_JUPYTERJSC_TOKEN} dist/*
+  rules:
+    - if: '$CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+(-.+)$/'
+
+
+###
+#
+# Prepare release notes
+#
+###
+
+prepare_job:
+  stage: prepare-release
+  image: alpine:latest
+  rules:
+  - if: '$CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/'
+  script:
+    - apk add curl jq
+    - 'curl -H "PRIVATE-TOKEN: $CI_API_TOKEN" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/changelog?version=$CI_COMMIT_TAG" | jq -r .notes > release_notes.md'
+    - 'curl -H "PRIVATE-TOKEN: $CI_API_TOKEN" -X POST "$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/changelog?version=$CI_COMMIT_TAG"'
+  artifacts:
+    paths:
+    - release_notes.md
+
+###
+#
+# Create release
+#
+###
+
+release_job:
+  stage: release
+  image: registry.gitlab.com/gitlab-org/release-cli:latest
+  needs:
+    - job: prepare_job
+      artifacts: true
+  rules:
+  - if: '$CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/'
+  script:
+    - echo "Creating release"
+  release:
+    name: 'Release $CI_COMMIT_TAG'
+    description: release_notes.md
+    tag_name: '$CI_COMMIT_TAG'
+    ref: '$CI_COMMIT_SHA'
+    assets:
+      links:
+        - name: 'jupyterhub-unicorespawner python package $CI_COMMIT_TAG'
+          url: "https://pypi.org/project/jupyterhub-unicorespawner/${CI_COMMIT_TAG}/"