From 1402c4d42516f3cd27dc071da09d012655f7b2d9 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Wed, 11 Feb 2026 15:16:17 +0000 Subject: [PATCH 1/3] Add a new CI step to validate the output of conver.py against staging For now I've set it to run on all PRs for testing, but I'll restrict it to the prod branch once it seems to work. --- .github/workflows/staging-check.yml | 25 ++++++ tools/compare_out_files.py | 134 ++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 .github/workflows/staging-check.yml create mode 100644 tools/compare_out_files.py diff --git a/.github/workflows/staging-check.yml b/.github/workflows/staging-check.yml new file mode 100644 index 0000000..53c2b7c --- /dev/null +++ b/.github/workflows/staging-check.yml @@ -0,0 +1,25 @@ +name: Validate against staging + +on: [pull_request] + +jobs: + validate_against_stating: + 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/ + diff --git a/tools/compare_out_files.py b/tools/compare_out_files.py new file mode 100644 index 0000000..ebfc62d --- /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): + 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() From e7771932eb8921b1243f4d512c04b757805d9b4b Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Wed, 11 Feb 2026 17:27:58 +0000 Subject: [PATCH 2/3] Comment on the PR with the generated_files.json diff with prod --- .github/workflows/staging-check.yml | 5 +- tools/calculate_generated_files_diff.py | 84 +++++++++++++++++++++++++ tools/compare_out_files.py | 2 +- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tools/calculate_generated_files_diff.py diff --git a/.github/workflows/staging-check.yml b/.github/workflows/staging-check.yml index 53c2b7c..952ac17 100644 --- a/.github/workflows/staging-check.yml +++ b/.github/workflows/staging-check.yml @@ -1,4 +1,4 @@ -name: Validate against staging +name: Validate output against staging on: [pull_request] @@ -23,3 +23,6 @@ jobs: 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 }} \ No newline at end of file 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 index ebfc62d..4118c5e 100644 --- a/tools/compare_out_files.py +++ b/tools/compare_out_files.py @@ -43,7 +43,7 @@ def get_and_compare(file_name: str, base_url: str, local_folder: str) -> str: ) ) - if len(deltas): + 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") From 5ee6504e99a3da3666a597e2e31fc2d3de8549fe Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Wed, 11 Feb 2026 17:33:06 +0000 Subject: [PATCH 3/3] Restrict token use permissions --- .github/workflows/staging-check.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/staging-check.yml b/.github/workflows/staging-check.yml index 952ac17..5f248a4 100644 --- a/.github/workflows/staging-check.yml +++ b/.github/workflows/staging-check.yml @@ -4,6 +4,9 @@ on: [pull_request] jobs: validate_against_stating: + permissions: + pull-requests: write + runs-on: ubuntu-latest env: @@ -25,4 +28,4 @@ jobs: - 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 }} \ No newline at end of file + 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 }}