diff --git a/.gitignore b/.gitignore
index 2e96438afb09d6302c01d60fbf0b922a7945a232..882fc836440d9a46bfc3ae45826f6292d584d393 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,4 @@ Pipfile
 Pipfile.lock
 **/__pycache__
 .coverage
+dist
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..561f358890ec22b489422f81a9a4371f6345a374
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,28 @@
+# vim: ts=2
+
+stages:
+  - testing
+  - release
+
+run-tests:
+  stage: testing
+  script:
+    - pip install --upgrade pip pipenv
+    - pipenv --rm || true
+    - pipenv run pip install -e '.[tests]'
+    - pipenv run pytest
+  coverage: /TOTAL.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/
+
+pypi-release:
+  stage: release
+  needs:
+    - run-tests
+  rules:
+    - if: '$CI_COMMIT_TAG =~ /^v\d+/'
+  script:
+    - pip3 install --upgrade pip build twine check-manifest
+    - rm -f dist/*
+    - python3 -m check_manifest
+    - python3 -m build
+    - python3 -m twine check dist/*
+    - TWINE_USERNAME=${PYPI_USER} TWINE_PASSWORD=${PYPI_PASSWORD} python3 -m twine upload --skip-existing --non-interactive dist/*
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000000000000000000000000000000000000..88db298a9d5338431d8ed6b418bf55a5217260ab
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,8 @@
+exclude .gitlab-ci.yml
+exclude *.sqlite
+exclude *.log
+exclude *.yml
+
+include MANIFEST.in
+include LICENSE
+recursive-include tests *.py *.yml