diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6bb0e3d80cc43f8a5f104778a1008c61f16e00b7
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,9 @@
+image: debian:buster
+
+before_script:
+- apt-get update -y
+- apt-get install -y python3 python3-yaml
+
+run-validate:
+  script:
+  - scripts/validate-lorries
diff --git a/README b/README
index 97edebf6d5d5013c7d7a88b43b9a2932bb8aea5d..e7d568e27062b784ce2c5f85b966c2e863213431 100644
--- a/README
+++ b/README
@@ -16,3 +16,19 @@ add any additional configuration to this repository.
 Remember, the Lorry tool is not permitted to manage repositories inside your
 prefix which is baserock.
 
+Validation
+----------
+
+Run `scripts/validate-lorries` to check the `.lorry` files.  This will
+report:
+
+* Invalid YAML/JSON syntax
+* Wrong data types
+* Duplicate repository names
+* Missing required keys
+* Unexpected keys
+* Invalid values
+
+The syntax and duplicate name checks are applied even to disabled
+`.lorry` files, because reusing a repository name for a different
+upstream type generally won't work.
diff --git a/open-source-lorries-disabled/boost.lorry b/open-source-lorries-disabled/boost.lorry
deleted file mode 100644
index 2a0e4d2a95b9594b3b7551c83ebf1521b8373f9b..0000000000000000000000000000000000000000
--- a/open-source-lorries-disabled/boost.lorry
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-    "boost": {
-        "type": "svn",
-        "url": "http://svn.boost.org/svn/boost/",
-        "layout": "standard"
-    }
-}
diff --git a/open-source-lorries/libmicrohttpd.lorry b/open-source-lorries/libmicrohttpd.lorry
deleted file mode 100644
index 45b847e4647f843d91a3f82af514b6dc51ebd3e7..0000000000000000000000000000000000000000
--- a/open-source-lorries/libmicrohttpd.lorry
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-    "coreutils": {
-        "type": "svn",
-        "url": "https://gnunet.org/svn/libmicrohttpd",
-        "layout": "standard"
-    }
-}
diff --git a/scripts/validate-lorries b/scripts/validate-lorries
new file mode 100755
index 0000000000000000000000000000000000000000..0d1d880d5a3b9a3051039009bbc46ab09e00cb01
--- /dev/null
+++ b/scripts/validate-lorries
@@ -0,0 +1,176 @@
+#!/usr/bin/python3
+
+import glob
+import json
+import sys
+import yaml
+
+
+def check_string(v):
+    if not isinstance(v, str):
+        return 'must be a string'
+
+
+def check_bool(v):
+    if not isinstance(v, bool):
+        return 'must be a boolean'
+
+
+def check_type_string(v):
+    err = check_string(v)
+    if err is not None:
+        return err
+
+    if v not in ['bzr', 'cvs', 'git', 'gzip', 'hg', 'svn', 'tarball', 'zip']:
+        return '"%s" is not a recognised type' % v
+
+
+def check_stringlist(v):
+    if not isinstance(v, list):
+        return 'must be a list'
+
+    for elem in v:
+        if not isinstance(elem, str):
+            return 'must have strings as elements'
+
+
+def check_stringdict(v):
+    if not isinstance(v, dict):
+        return 'must be a dictionary'
+
+    for key, value in v.items():
+        if not (isinstance(key, str) and isinstance(value, str)):
+            return 'must have strings as keys and value'
+
+
+def check_svn_layout(v):
+    if v == 'standard':
+        return
+
+    if not isinstance(v, dict):
+        return 'must be either "standard" or a dictionary'
+
+    return check_stringdict(v)
+
+
+def validate(filename, repo_filenames, strict=True):
+    is_ok = True
+    repo_name = None
+
+    def diagnostic(level, msg):
+        if repo_name is None:
+            print('%s: %s: %s' % (level, filename, msg),
+                  file=sys.stderr)
+        else:
+            print('%s: %s: %s: %s' % (level, filename, repo_name, msg),
+                  file=sys.stderr)
+
+    def error(msg):
+        nonlocal is_ok
+        is_ok = False
+        diagnostic('E', msg)
+
+    def warning(msg):
+        diagnostic('W', msg)
+
+    with open(filename) as f:
+        try:
+            try:
+                obj = yaml.safe_load(f)
+            except yaml.YAMLError:
+                f.seek(0)
+                obj = json.load(f)
+        except ValueError:
+            error('not valid YAML or JSON')
+            return is_ok
+
+        if not isinstance(obj, dict):
+            error('must be a dictionary')
+            return is_ok
+
+        for repo_name, upstream_def in obj.items():
+            if repo_name in repo_filenames:
+                error('repository already defined in %s'
+                      % repo_filenames[repo_name])
+            else:
+                repo_filenames[repo_name] = filename
+            if not strict:
+                continue
+
+            upstream_type = upstream_def.get('type')
+
+            value_checkers = {
+                # Keys listed in Lorry's README
+                'type':               check_type_string,
+                'url':                check_string,
+                'check-certificates': check_bool,
+                'branches':           check_stringdict,
+                'layout':             check_svn_layout,
+                'module':             check_string,
+                # Undocumented Lorry feature
+                'refspecs':           check_stringlist,
+                # Lorry Controller extension
+                'description':        check_string,
+            }
+
+            required_keys = set(['type'])
+            optional_keys = set(['refspecs', 'description'])
+            if upstream_type != 'bzr':
+                required_keys.add('url')
+            else:
+                optional_keys.add('url')
+                optional_keys.add('branches')
+            if upstream_type in ['bzr', 'git', 'hg']:
+                optional_keys.add('check-certificates')
+            if upstream_type == 'svn':
+                required_keys.add('layout')
+            if upstream_type == 'cvs':
+                required_keys.add('module')
+
+            for key in required_keys:
+                if key not in upstream_def:
+                    error('missing "%s" key' % key)
+
+            # For bzr, exactly one of url and branches keys is required
+            if upstream_type == 'bzr':
+                has_url = 'url' in upstream_def
+                has_branches = 'branches' in upstream_def
+                if has_url and has_branches:
+                    error('has both "url" and "branches" keys')
+                elif not has_url and not has_branches:
+                    error('missing both "url" and "branches" keys')
+
+            for key, value in upstream_def.items():
+                if key.startswith('x-products-'):
+                    # Baserock Import extension
+                    msg = check_stringlist(value)
+                else:
+                    if key not in required_keys and key not in optional_keys:
+                        warning('unexpected "%s" key' % key)
+                    if key in value_checkers:
+                        msg = value_checkers[key](value)
+                    else:
+                        msg = None
+                if msg:
+                    error('%s: %s' % (key, msg))
+
+    return is_ok
+
+
+def main():
+    repo_filenames = {}
+    all_ok = True
+
+    for filename in glob.glob('*-lorries/*.lorry'):
+        if not validate(filename, repo_filenames):
+            all_ok = False
+
+    for filename in glob.glob('*-lorries-disabled/*.lorry'):
+        if not validate(filename, repo_filenames, strict=False):
+            all_ok = False
+
+    sys.exit(0 if all_ok else 1)
+
+
+if __name__ == '__main__':
+    main()