diff --git a/.github/deploy.sh b/.github/deploy.sh
index e85e8874ba6..a3c57232f55 100644
--- a/.github/deploy.sh
+++ b/.github/deploy.sh
@@ -8,13 +8,12 @@ rm -rf out/master/ || exit 0
 echo "Making the docs for master"
 mkdir out/master/
 cp util/gh-pages/index.html out/master
-python3 ./util/export.py out/master/lints.json
+cp util/gh-pages/lints.json out/master
 
 if [[ -n $TAG_NAME ]]; then
   echo "Save the doc for the current tag ($TAG_NAME) and point stable/ to it"
-  cp -r out/master "out/$TAG_NAME"
-  rm -f out/stable
-  ln -s "$TAG_NAME" out/stable
+  cp -Tr out/master "out/$TAG_NAME"
+  ln -sf "$TAG_NAME" out/stable
 fi
 
 if [[ $BETA = "true" ]]; then
@@ -28,8 +27,8 @@ cp util/gh-pages/versions.html out/index.html
 echo "Making the versions.json file"
 python3 ./util/versions.py out
 
-cd out
 # Now let's go have some fun with the cloned repo
+cd out
 git config user.name "GHA CI"
 git config user.email "gha@ci.invalid"
 
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 15aeaf907dc..b8be730be32 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -39,10 +39,23 @@ jobs:
       if: github.ref == 'refs/heads/beta'
       run: echo "BETA=true" >> $GITHUB_ENV
 
-    - name: Use scripts and templates from master branch
+    # We need to check out all files that (transitively) depend on the
+    # structure of the gh-pages branch, so that we're able to change that
+    # structure without breaking the deployment.
+    - name: Use deploy files from master branch
       run: |
         git fetch --no-tags --prune --depth=1 origin master
-        git checkout origin/master -- .github/deploy.sh util/gh-pages/ util/*.py
+        git checkout origin/master -- .github/deploy.sh util/versions.py util/gh-pages/versions.html
+
+    # Generate lockfile for caching to avoid build problems with cached deps
+    - name: cargo generate-lockfile
+      run: cargo generate-lockfile
+
+    - name: Cache
+      uses: Swatinem/rust-cache@v1.3.0
+
+    - name: cargo collect-metadata
+      run: cargo collect-metadata
 
     - name: Deploy
       run: |
diff --git a/clippy_dev/src/serve.rs b/clippy_dev/src/serve.rs
index d13c27a1957..b36e2a28ee4 100644
--- a/clippy_dev/src/serve.rs
+++ b/clippy_dev/src/serve.rs
@@ -15,8 +15,8 @@ pub fn run(port: u16, lint: Option<&str>) -> ! {
 
     loop {
         if mtime("util/gh-pages/lints.json") < mtime("clippy_lints/src") {
-            Command::new("python3")
-                .arg("util/export.py")
+            Command::new("cargo")
+                .arg("collect-metadata")
                 .spawn()
                 .unwrap()
                 .wait()
diff --git a/clippy_lints/src/utils/internal_lints/metadata_collector.rs b/clippy_lints/src/utils/internal_lints/metadata_collector.rs
index c9234a85dd2..4a6861bd936 100644
--- a/clippy_lints/src/utils/internal_lints/metadata_collector.rs
+++ b/clippy_lints/src/utils/internal_lints/metadata_collector.rs
@@ -32,7 +32,7 @@ use clippy_utils::{
 };
 
 /// This is the output file of the lint collector.
-const OUTPUT_FILE: &str = "../util/gh-pages/metadata_collection.json";
+const OUTPUT_FILE: &str = "../util/gh-pages/lints.json";
 /// These lints are excluded from the export.
 const BLACK_LISTED_LINTS: [&str; 3] = ["lint_author", "deep_code_inspection", "internal_metadata_collector"];
 /// These groups will be ignored by the lint group matcher. This is useful for collections like
diff --git a/tests/dogfood.rs b/tests/dogfood.rs
index a996f9df144..4ede20c5258 100644
--- a/tests/dogfood.rs
+++ b/tests/dogfood.rs
@@ -183,7 +183,7 @@ fn run_metadata_collection_lint() {
     use std::time::SystemTime;
 
     // Setup for validation
-    let metadata_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("util/gh-pages/metadata_collection.json");
+    let metadata_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("util/gh-pages/lints.json");
     let start_time = SystemTime::now();
 
     // Run collection as is
diff --git a/util/export.py b/util/export.py
deleted file mode 100755
index 1248e6b6a26..00000000000
--- a/util/export.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env python
-
-# Build the gh-pages
-
-from collections import OrderedDict
-import re
-import sys
-import json
-
-from lintlib import parse_all, log
-
-lint_subheadline = re.compile(r'''^\*\*([\w\s]+?)[:?.!]?\*\*(.*)''')
-rust_code_block = re.compile(r'''```rust.+?```''', flags=re.DOTALL)
-
-CONF_TEMPLATE = """\
-This lint has the following configuration variables:
-
-* `%s: %s`: %s (defaults to `%s`)."""
-
-
-def parse_code_block(match):
-    lines = []
-
-    for line in match.group(0).split('\n'):
-        # fix syntax highlighting for headers like ```rust,ignore
-        if line.startswith('```rust'):
-            lines.append('```rust')
-        elif not line.startswith('# '):
-            lines.append(line)
-
-    return '\n'.join(lines)
-
-
-def parse_lint_def(lint):
-    lint_dict = {}
-    lint_dict['id'] = lint.name
-    lint_dict['group'] = lint.group
-    lint_dict['level'] = lint.level
-    lint_dict['docs'] = OrderedDict()
-
-    last_section = None
-
-    for line in lint.doc:
-        match = re.match(lint_subheadline, line)
-        if match:
-            last_section = match.groups()[0]
-            text = match.groups()[1]
-        else:
-            text = line
-
-        if not last_section:
-            log.warning("Skipping comment line as it was not preceded by a heading")
-            log.debug("in lint `%s`, line `%s`", lint.name, line)
-
-        if last_section not in lint_dict['docs']:
-            lint_dict['docs'][last_section] = ""
-
-        lint_dict['docs'][last_section] += text + "\n"
-
-    for section in lint_dict['docs']:
-        lint_dict['docs'][section] = re.sub(rust_code_block, parse_code_block, lint_dict['docs'][section].strip())
-
-    return lint_dict
-
-
-def main():
-    lintlist, configs = parse_all()
-    lints = {}
-    for lint in lintlist:
-        lints[lint.name] = parse_lint_def(lint)
-        if lint.name in configs:
-            lints[lint.name]['docs']['Configuration'] = \
-                CONF_TEMPLATE % configs[lint.name]
-
-    outfile = sys.argv[1] if len(sys.argv) > 1 else "util/gh-pages/lints.json"
-    with open(outfile, "w") as fp:
-        lints = list(lints.values())
-        lints.sort(key=lambda x: x['id'])
-        json.dump(lints, fp, indent=2)
-        log.info("wrote JSON for great justice")
-
-
-if __name__ == "__main__":
-    main()
diff --git a/util/lintlib.py b/util/lintlib.py
deleted file mode 100644
index 9cefb2dbb19..00000000000
--- a/util/lintlib.py
+++ /dev/null
@@ -1,115 +0,0 @@
-# Common utils for the several housekeeping scripts.
-
-import os
-import re
-import collections
-
-import logging as log
-log.basicConfig(level=log.INFO, format='%(levelname)s: %(message)s')
-
-Lint = collections.namedtuple('Lint', 'name level doc sourcefile group')
-Config = collections.namedtuple('Config', 'name ty doc default')
-
-lintname_re = re.compile(r'''pub\s+([A-Z_][A-Z_0-9]*)''')
-group_re = re.compile(r'''\s*([a-z_][a-z_0-9]+)''')
-conf_re = re.compile(r'''define_Conf! {\n((?!\n})[\s\S])*\n}''', re.MULTILINE)
-confvar_re = re.compile(
-    r'''/// Lint: ([\w,\s]+)\. (.*)\n\s*\(([^:]+):\s*([^\s=]+)\s*=\s*([^\.\)]+).*\),''', re.MULTILINE)
-comment_re = re.compile(r'''\s*/// ?(.*)''')
-
-lint_levels = {
-    "correctness": 'Deny',
-    "suspicious": 'Warn',
-    "style": 'Warn',
-    "complexity": 'Warn',
-    "perf": 'Warn',
-    "restriction": 'Allow',
-    "pedantic": 'Allow',
-    "nursery": 'Allow',
-    "cargo": 'Allow',
-}
-
-
-def parse_lints(lints, filepath):
-    comment = []
-    clippy = False
-    deprecated = False
-    name = ""
-
-    with open(filepath) as fp:
-        for line in fp:
-            if clippy or deprecated:
-                m = lintname_re.search(line)
-                if m:
-                    name = m.group(1).lower()
-                    line = next(fp)
-
-                    if deprecated:
-                        level = "Deprecated"
-                        group = "deprecated"
-                    else:
-                        while True:
-                            g = group_re.search(line)
-                            if g:
-                                group = g.group(1).lower()
-                                level = lint_levels.get(group, None)
-                                break
-                            line = next(fp)
-
-                    if level is None:
-                        continue
-
-                    log.info("found %s with level %s in %s",
-                             name, level, filepath)
-                    lints.append(Lint(name, level, comment, filepath, group))
-                    comment = []
-
-                    clippy = False
-                    deprecated = False
-                    name = ""
-                else:
-                    m = comment_re.search(line)
-                    if m:
-                        comment.append(m.group(1))
-            elif line.startswith("declare_clippy_lint!"):
-                clippy = True
-                deprecated = False
-            elif line.startswith("declare_deprecated_lint!"):
-                clippy = False
-                deprecated = True
-            elif line.startswith("declare_lint!"):
-                import sys
-                print(
-                    "don't use `declare_lint!` in Clippy, "
-                    "use `declare_clippy_lint!` instead"
-                )
-                sys.exit(42)
-
-
-def parse_configs(path):
-    configs = {}
-    with open(os.path.join(path, 'utils/conf.rs')) as fp:
-        contents = fp.read()
-
-    match = re.search(conf_re, contents)
-    confvars = re.findall(confvar_re, match.group(0))
-
-    for (lints, doc, name, ty, default) in confvars:
-        for lint in lints.split(','):
-            configs[lint.strip().lower()] = Config(name.replace("_", "-"), ty, doc, default)
-    return configs
-
-
-def parse_all(path="clippy_lints/src"):
-    lints = []
-    for root, dirs, files in os.walk(path):
-        for fn in files:
-            if fn.endswith('.rs'):
-                parse_lints(lints, os.path.join(root, fn))
-
-    log.info("got %s lints", len(lints))
-
-    configs = parse_configs(path)
-    log.info("got %d configs", len(configs))
-
-    return lints, configs
diff --git a/util/versions.py b/util/versions.py
index 5cdc7313f54..0cfa007d1b2 100755
--- a/util/versions.py
+++ b/util/versions.py
@@ -3,8 +3,8 @@
 import json
 import os
 import sys
-
-from lintlib import log
+import logging as log
+log.basicConfig(level=log.INFO, format='%(levelname)s: %(message)s')
 
 
 def key(v):
@@ -26,7 +26,7 @@ def key(v):
 
 def main():
     if len(sys.argv) < 2:
-        print("Error: specify output directory")
+        log.error("specify output directory")
         return
 
     outdir = sys.argv[1]