diff --git a/.github/workflows/staging-check.yml b/.github/workflows/staging-check.yml
new file mode 100644
index 0000000..5f248a4
--- /dev/null
+++ b/.github/workflows/staging-check.yml
@@ -0,0 +1,31 @@
+name: Validate output against staging
+
+on: [pull_request]
+
+jobs:
+ validate_against_stating:
+ permissions:
+ pull-requests: write
+
+ runs-on: ubuntu-latest
+
+ env:
+ PYTHONDEVMODE: 1
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Run deployment script
+ run: |
+ pip install lxml
+ mkdir tmp/
+ python tools/convert.py -a -d tmp ispdb/*
+
+ - name: Compare output against staging
+ run: |
+ pip install requests
+ python tools/compare_out_files.py -b https://autoconfig-stage.thunderbird.net/v1.1/ tmp/
+
+ - name: Calculate generated_files.json diff with prod
+ run: |
+ python tools/calculate_generated_files_diff.py -b https://autoconfig.thunderbird.net/v1.1 -t ${{ secrets.GITHUB_TOKEN }} -r ${{ github.repository }} -n ${{ github.event.pull_request.number }}
diff --git a/tools/calculate_generated_files_diff.py b/tools/calculate_generated_files_diff.py
new file mode 100644
index 0000000..fa1561e
--- /dev/null
+++ b/tools/calculate_generated_files_diff.py
@@ -0,0 +1,84 @@
+import argparse
+import difflib
+import os.path
+import requests
+
+GENERATED_FILES_NAME = "generated_files.json"
+
+GITHUB_COMMENT_TEMPLATE_WITH_DIFF = """This PR will cause the following changes to the production `generated_files.json` file:
+
+
+
+Expand to view diff
+
+
+```diff
+{deltas}
+```
+
+
+"""
+
+GITHUB_COMMENT_TEMPLATE_NO_DIFF = (
+ "This PR will not cause any change to the production `generated_files.json` file."
+)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-b", metavar="base_url", help="base URL serving ISPDB files")
+ parser.add_argument("-t", metavar="api_token", help="Github API token")
+ parser.add_argument("-r", metavar="repo", help="Github repository")
+ parser.add_argument("-n", metavar="number", help="The Github or issue number")
+ parser.add_argument(
+ "folder", help="the folder containing the local ISPDB files to compare"
+ )
+
+ args = parser.parse_args()
+
+ # Strip out any trailing slash in the base URL so we don't accidentally end
+ # up doubling it.
+ base_url: str = args.b.strip("/")
+
+ resp = requests.get(f"{base_url}/{GENERATED_FILES_NAME}")
+
+ # At the time of writing, all of the domains in ISPDB are made up of ASCII
+ # characters, but that might not stay true forever.
+ resp.encoding = "utf-8"
+
+ with open(os.path.join(args.folder, GENERATED_FILES_NAME), "r") as fp:
+ local_list = fp.readlines()
+
+ # We call the local version "staging" as a shortcut, because by this
+ # time we expect to have already validated that the
+ deltas = list(
+ difflib.unified_diff(
+ local_list,
+ resp.text.splitlines(keepends=True),
+ fromfile="staging",
+ tofile="production",
+ )
+ )
+
+ comment = (
+ GITHUB_COMMENT_TEMPLATE_WITH_DIFF.format(deltas="".join(deltas))
+ if len(deltas) > 0
+ else GITHUB_COMMENT_TEMPLATE_NO_DIFF
+ )
+
+ # Create the comment via the Github API.
+ # See
+ resp = requests.post(
+ f"https://api.github.com/repos/{args.r}/issues/{args.n}/comments",
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {args.t}",
+ },
+ json={"body": comment},
+ )
+
+ print(f"Posted comment {resp.json()["html_url"]}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/compare_out_files.py b/tools/compare_out_files.py
new file mode 100644
index 0000000..4118c5e
--- /dev/null
+++ b/tools/compare_out_files.py
@@ -0,0 +1,134 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import argparse
+import difflib
+import json
+import os.path
+import sys
+import requests
+from typing import Dict, List
+
+GENERATED_FILES_NAME = "generated_files.json"
+
+
+def get_and_compare(file_name: str, base_url: str, local_folder: str) -> str:
+ """Reads a local file and compare it with its remote copy before returning
+ its content.
+
+ Returns:
+ The file's content as served by the remote server, decoded as UTF-8
+ text.
+
+ Raises:
+ RuntimeError if the local file's content doesn't match the remote copy.
+ """
+ resp = requests.get(f"{base_url}/{file_name}")
+
+ # The response might not include an content-type header, and there are some
+ # non-ASCII characters in our XML files (e.g. in display names), so we need
+ # to explicitly tell `resp` what its encoding is.
+ resp.encoding = "utf-8"
+
+ with open(os.path.join(local_folder, file_name), "r") as fp:
+ local_list = fp.readlines()
+
+ deltas = list(
+ difflib.unified_diff(
+ local_list,
+ resp.text.splitlines(keepends=True),
+ fromfile="local",
+ tofile="remote",
+ )
+ )
+
+ if len(deltas) > 0:
+ print(f"Diff deltas:\n\n{"".join(deltas)}", file=sys.stderr)
+ raise RuntimeError("local file list does not match staging copy")
+
+ return resp.text
+
+
+def get_file_list(base_url: str, local_folder: str) -> List[str]:
+ """Gets the list of files to compare.
+
+ Returns:
+ The list of file names as per the `generated_files.json` file.
+
+ Raises:
+ RuntimeError if the local `generated_files.json` file does not match the
+ remote copy.
+ """
+ file_list = get_and_compare(GENERATED_FILES_NAME, base_url, local_folder)
+ return json.loads(file_list)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-b", metavar="base_url", help="base URL serving ISPDB files")
+ parser.add_argument(
+ "folder", help="the folder containing the local ISPDB files to compare"
+ )
+
+ args = parser.parse_args()
+
+ # Strip out any trailing slash in the base URL so we don't accidentally end
+ # up doubling it.
+ base_url: str = args.b.strip("/")
+
+ print("Fetching and comparing file list")
+
+ listed_files = get_file_list(base_url, args.folder)
+
+ failed_files: Dict[str, Exception] = {}
+ for file in listed_files:
+ print(f"Fetching and comparing {file}")
+
+ try:
+ get_and_compare(file, base_url, args.folder)
+ except Exception as e:
+ print(f"Comparison failed for file {file}: {e}", file=sys.stderr)
+ failed_files[file] = e
+
+ if len(failed_files) > 0:
+ # Print the failed files, preceded by an empty line to separate them
+ # from the previous logs.
+ print("\nComparing the following file(s) has failed:", file=sys.stderr)
+
+ for file, exc in failed_files.items():
+ print(f"{file}: {exc}", file=sys.stderr)
+
+ # Check if we can find files that exist in the local directory but isn't
+ # listed in `generated_files.json`. We could also do this check in the other
+ # direction (i.e. check if a file in `generated_files.json` is missing from
+ # the local directory), but if a file from the list is missing then trying
+ # to open it earlier will have raised an exception and will already cause
+ # the script to fail.
+ local_files = os.listdir(args.folder)
+
+ # Make sure we don't try to find the JSON list file in itself.
+ local_files.remove(GENERATED_FILES_NAME)
+
+ unknown_files = []
+ for local_file in local_files:
+ if local_file not in listed_files:
+ unknown_files.append(local_file)
+
+ if len(unknown_files) > 0:
+ print("\nUnknown file(s) in local directory:", file=sys.stderr)
+
+ for file in unknown_files:
+ print(file, file=sys.stderr)
+ else:
+ print("No unknown files found")
+
+ # Fail the script if either a comparison has failed or we found an unknown
+ # file. We could fail earlier, but it's more helpful for troubleshooting if
+ # we have the script point out as many issues in one run as possible.
+ if len(failed_files) > 0 or len(unknown_files) > 0:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()