diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 210486aceb10d7a7c4eb7c98a3ddd0afde89b70a..50a346987f40a460f38037b3cbc0a8f3a6e568b2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -14,9 +14,18 @@
 # As above, but rules for running when the scheduler triggers the pipeline.
 .schedule_rules: &RUN_ON_SCHEDULE
   rules:
-    - if: '$CI_PIPELINE_SOURCE == "schedule"'
+    # There are multiple schedules so make sure this one is for us.  The
+    # `SCHEDULE_TARGET` variable is explicitly, manually set by us in the
+    # schedule configuration.
+    - if: '$SCHEDULE_TARGET != $CI_JOB_NAME'
+      when: "never"
 
-    - when: "never"
+    # Make sure this is actually a scheduled run
+    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+      when: "never"
+
+    # Conditions look good: run.
+    - when: "always"
 
 stages:
   - "build"
@@ -167,3 +176,14 @@ update-nixpkgs:
               "$CI_PROJECT_PATH" \
               "$CI_PROJECT_ID" \
               "$CI_DEFAULT_BRANCH"
+
+update-production:
+  <<: *RUN_ON_SCHEDULE
+  stage: "build"
+  script:
+    - |
+      ./ci-tools/update-production \
+              "$CI_SERVER_URL" \
+              "$CI_PROJECT_ID" \
+              "develop" \
+              "production"
diff --git a/ci-tools/update-nixpkgs b/ci-tools/update-nixpkgs
index 76f74a2e1848d1504af6925ae1991fdf335ad80f..d4afb21ff9689bd599d1d0fd2ca08089f8767d87 100755
--- a/ci-tools/update-nixpkgs
+++ b/ci-tools/update-nixpkgs
@@ -54,8 +54,11 @@ main() {
     checkout_source_branch "$SSHKEY" "$SERVER_HOST" "$PROJECT_PATH" "$DEFAULT_BRANCH" "$SOURCE_BRANCH"
     build "result-before"
 
-    # If nothing changed, update_nixpkgs will just exit for us.
-    update_nixpkgs
+    # If nothing changed, report this and exit without an error.
+    if ! update_nixpkgs; then
+	echo "No changes."
+	exit 0
+    fi
 
     build "result-after"
     local DIFF=$(compute_diff "./result-before" "./result-after")
@@ -156,11 +159,10 @@ update_nixpkgs() {
     # file instead of the one that's part of its own checkout.
     nix-shell ../shell.nix --run 'update-nixpkgs ${PWD}/nixpkgs.json'
 
-    # Show us what we did - and signal a kind of error if we did nothing
-    # (expected in the case where nixpkgs hasn't changed since we last ran).
+    # Signal a kind of error if we did nothing (expected in the case where
+    # nixpkgs hasn't changed since we last ran).
     if git diff --exit-code; then
-	echo "No changes."
-	exit 0
+	return 1
     fi
 }
 
diff --git a/ci-tools/update-production b/ci-tools/update-production
new file mode 100755
index 0000000000000000000000000000000000000000..7892bce0922075188e92110d1b289528b2b51b22
--- /dev/null
+++ b/ci-tools/update-production
@@ -0,0 +1,162 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p git curl python3
+
+set -eux -o pipefail
+
+main() {
+    local TOKEN=$1
+    shift
+    local SERVER_URL=$1
+    shift
+    local PROJECT_ID=$1
+    shift
+    local SOURCE_BRANCH=$1
+    shift
+    local TARGET_BRANCH=$1
+    shift
+
+    # Make sure the things we want to talk about are locally known.  GitLab
+    # seems to prefer to know about as few refs as possible.
+    checkout_git_ref "$SOURCE_BRANCH"
+    checkout_git_ref "$TARGET_BRANCH"
+
+    # If there have been no changes we'll just abandon this update.
+    if ! ensure_changes "$SOURCE_BRANCH" "$TARGET_BRANCH"; then
+	echo "No changes."
+	exit 0
+    fi
+
+    local NOTES=$(describe_update "$SOURCE_BRANCH" "$TARGET_BRANCH")
+
+    create_merge_request "$TOKEN" "$SERVER_URL" "$PROJECT_ID" "$SOURCE_BRANCH" "$TARGET_BRANCH" "$NOTES"
+}
+
+checkout_git_ref() {
+    local REF=$1
+    shift
+
+    git fetch origin "$REF"
+}
+
+ensure_changes() {
+    local SOURCE_BRANCH=$1
+    shift
+    local TARGET_BRANCH=$1
+    shift
+
+    if [ "$(git rev-parse origin/"$SOURCE_BRANCH")" = "$(git rev-parse origin/"$TARGET_BRANCH")" ]; then
+	return 1
+    fi
+}
+
+describe_merge_request() {
+    git show $rev | grep 'See merge request' | sed -e 's/See merge request //' | tr -d '[:space:]'
+}
+
+describe_merge_requests() {
+    local RANGE=$1
+    shift
+    local TARGET=$1
+    shift
+
+    # Find all of the relevant merge revisions
+    local onelines=$(git log --merges --first-parent -m --oneline "$RANGE" | grep "into '$TARGET'")
+
+    # Describe each merge revision
+    local IFS=$'\n'
+    for line in $onelines; do
+	local rev=$(echo "$line" | cut -d ' ' -f 1)
+	echo -n "* "
+	describe_merge_request $rev
+	echo
+    done
+}
+
+describe_update() {
+    local SOURCE_BRANCH=$1
+    shift
+    local TARGET_BRANCH=$1
+    shift
+
+    # Since production production (target) should not diverge from develop
+    # (source) it is fine to use `..` instead of `...` in the git ranges here.
+    # `...` encounters problems related to discovering the merge base because
+    # of the way GitLab manages the git checkout on CI (I think).
+
+    local NOTES=$(git diff origin/"$TARGET_BRANCH"..origin/"$SOURCE_BRANCH" -- DEPLOYMENT-NOTES.rst)
+
+    # There often are no notes and that makes for boring reading so toss in a
+    # diffstat as well.
+    local DIFFSTAT=$(git diff --stat origin/"$TARGET_BRANCH"..origin/"$SOURCE_BRANCH")
+
+    local WHEN=$(git log --max-count=1 --format='%cI' origin/"$TARGET_BRANCH")
+
+    # Describe all of the MRs that were merged into the source branch that are
+    # about to be merged into the target branch.
+    local MR=$(describe_merge_requests origin/"$TARGET_BRANCH"..origin/"$SOURCE_BRANCH" "$SOURCE_BRANCH")
+
+    echo "\
+Changes from $SOURCE_BRANCH since $WHEN
+=======================================
+
+Deployment Notes
+----------------
+\`\`\`
+$NOTES
+\`\`\`
+
+Included Merge Requests
+-----------------------
+$MR
+
+Diff Stat
+---------
+\`\`\`
+$DIFFSTAT
+\`\`\`
+"
+}
+
+create_merge_request() {
+    local TOKEN=$1
+    shift
+    local SERVER_URL=$1
+    shift
+    local PROJECT_ID=$1
+    shift
+    # THe source branch of the MR.
+    local SOURCE_BRANCH=$1
+    shift
+    # The target branch of the MR.
+    local TARGET_BRANCH=$1
+    shift
+    local NOTES=$1
+    shift
+
+    local BODY=$(python3 -c '
+import sys, json
+print(json.dumps({
+    "id": sys.argv[1],
+    "source_branch": sys.argv[2],
+    "target_branch": sys.argv[3],
+    "remove_source_branch": True,
+    "title": f"update {sys.argv[3]}",
+    "description": sys.argv[4],
+}))
+' "$PROJECT_ID" "$SOURCE_BRANCH" "$TARGET_BRANCH" "$NOTES")
+
+    curl --verbose -X POST --data "${BODY}" --header "Content-Type: application/json" --header "PRIVATE-TOKEN: ${TOKEN}" "${SERVER_URL}/api/v4/projects/${PROJECT_ID}/merge_requests"
+}
+
+# Pull the GitLab token from the environment here so we can work with them as
+# arguments everywhere else.  They're passed to us in the environment because
+# *maybe* this is *slightly* safer than passing them in argv.
+#
+# The name is slightly weird because it is shared with the update-nixpkgs job.
+TOKEN="$UPDATE_NIXPKGS_PRIVATE_TOKEN"
+
+# Before proceeding, remove the secrets from our environment so we don't pass
+# them to child processes - none of which need them.
+unset UPDATE_NIXPKGS_PRIVATE_TOKEN
+
+main "$TOKEN" "$@"