|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Copyright (c) The mlkem-native project authors |
| 3 | +# SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT |
| 4 | + |
| 5 | +# |
| 6 | +# CI helper: compare the Wycheproof commit pinned by the test client against the |
| 7 | +# upstream default branch and, if the tracked test vectors have changed, open |
| 8 | +# (or refresh) a tracking issue in $GITHUB_REPOSITORY. |
| 9 | +# |
| 10 | +# Only testvectors_v1 files whose name begins with VECTOR_PREFIX are considered; |
| 11 | +# all other vectors are ignored. |
| 12 | +# |
| 13 | + |
| 14 | +import json |
| 15 | +import os |
| 16 | +import re |
| 17 | +import sys |
| 18 | +import urllib.error |
| 19 | +import urllib.request |
| 20 | +from pathlib import Path |
| 21 | + |
| 22 | +WYCHEPROOF_REPO = "C2SP/wycheproof" |
| 23 | +DEFAULT_BRANCH = "main" |
| 24 | +CONTENTS_URL = f"https://api.github.com/repos/{WYCHEPROOF_REPO}/contents/testvectors_v1" |
| 25 | +COMMIT_URL = f"https://api.github.com/repos/{WYCHEPROOF_REPO}/commits/{DEFAULT_BRANCH}" |
| 26 | + |
| 27 | +# Only testvectors_v1 files with this filename prefix are tracked. |
| 28 | +# To port to another scheme, change the prefix (e.g. "mldsa_"). |
| 29 | +VECTOR_PREFIX = "mlkem_" |
| 30 | + |
| 31 | +# Tracking issue label (used to dedupe). |
| 32 | +ISSUE_LABEL = "wycheproof-update" |
| 33 | + |
| 34 | +# Repository to open the tracking issue in, and the client holding the pin. |
| 35 | +REPO = os.environ["GITHUB_REPOSITORY"] |
| 36 | +CLIENT = Path(__file__).resolve().parents[2] / "test/wycheproof/wycheproof_client.py" |
| 37 | + |
| 38 | + |
| 39 | +def request(method, url, data=None): |
| 40 | + """Issue a GitHub API request and return the parsed JSON body (or None).""" |
| 41 | + headers = {"Accept": "application/vnd.github+json"} |
| 42 | + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") |
| 43 | + if token: |
| 44 | + headers["Authorization"] = f"Bearer {token}" |
| 45 | + body = None |
| 46 | + if data is not None: |
| 47 | + body = json.dumps(data).encode("utf-8") |
| 48 | + headers["Content-Type"] = "application/json" |
| 49 | + req = urllib.request.Request(url, data=body, headers=headers, method=method) |
| 50 | + with urllib.request.urlopen(req) as resp: |
| 51 | + raw = resp.read() |
| 52 | + return json.loads(raw) if raw else None |
| 53 | + |
| 54 | + |
| 55 | +def read_pinned_commit(): |
| 56 | + text = CLIENT.read_text(encoding="utf-8") |
| 57 | + m = re.search(r'^WYCHEPROOF_COMMIT\s*=\s*"([0-9a-f]{40})"', text, re.MULTILINE) |
| 58 | + if not m: |
| 59 | + sys.exit(f"Could not find WYCHEPROOF_COMMIT in {CLIENT}") |
| 60 | + return m.group(1) |
| 61 | + |
| 62 | + |
| 63 | +def tracked_blobs(ref): |
| 64 | + """Return {filename: blob_sha} for tracked files in testvectors_v1 at ref.""" |
| 65 | + entries = request("GET", f"{CONTENTS_URL}?ref={ref}") |
| 66 | + return { |
| 67 | + e["name"]: e["sha"] |
| 68 | + for e in entries |
| 69 | + if e["type"] == "file" and e["name"].startswith(VECTOR_PREFIX) |
| 70 | + } |
| 71 | + |
| 72 | + |
| 73 | +def build_report(): |
| 74 | + """Return (report_text, has_updates, head_sha, head_date).""" |
| 75 | + pinned_commit = read_pinned_commit() |
| 76 | + pinned = tracked_blobs(pinned_commit) |
| 77 | + latest = tracked_blobs(DEFAULT_BRANCH) |
| 78 | + commit = request("GET", COMMIT_URL) |
| 79 | + head = commit["sha"] |
| 80 | + head_date = commit["commit"]["committer"]["date"][:10] |
| 81 | + |
| 82 | + changed = sorted(f for f in pinned.keys() & latest.keys() if pinned[f] != latest[f]) |
| 83 | + added = sorted(latest.keys() - pinned.keys()) |
| 84 | + removed = sorted(pinned.keys() - latest.keys()) |
| 85 | + |
| 86 | + lines = [ |
| 87 | + f"Pinned commit: {pinned_commit}", |
| 88 | + f"Upstream {DEFAULT_BRANCH} HEAD: {head} ({head_date})", |
| 89 | + f"Compare: https://github.com/{WYCHEPROOF_REPO}/compare/{pinned_commit}...{head}", |
| 90 | + "", |
| 91 | + ] |
| 92 | + if not (changed or added or removed): |
| 93 | + lines.append("Test vectors are up to date.") |
| 94 | + return "\n".join(lines), False, head, head_date |
| 95 | + |
| 96 | + lines.append("Test vector updates detected upstream:") |
| 97 | + for f in changed: |
| 98 | + lines.append(f" changed: {f}") |
| 99 | + for f in added: |
| 100 | + lines.append(f" added: {f}") |
| 101 | + for f in removed: |
| 102 | + lines.append(f" removed: {f}") |
| 103 | + lines.append("") |
| 104 | + lines.append( |
| 105 | + f"To adopt, set WYCHEPROOF_COMMIT to {head} in " |
| 106 | + "test/wycheproof/wycheproof_client.py" |
| 107 | + ) |
| 108 | + if added: |
| 109 | + lines.append("and add the new file(s) to WYCHEPROOF_FILES.") |
| 110 | + return "\n".join(lines), True, head, head_date |
| 111 | + |
| 112 | + |
| 113 | +def ensure_label(): |
| 114 | + """Create the tracking label if it does not already exist.""" |
| 115 | + try: |
| 116 | + request( |
| 117 | + "POST", |
| 118 | + f"https://api.github.com/repos/{REPO}/labels", |
| 119 | + { |
| 120 | + "name": ISSUE_LABEL, |
| 121 | + "color": "ededed", |
| 122 | + "description": "Upstream Wycheproof vector update tracking", |
| 123 | + }, |
| 124 | + ) |
| 125 | + except urllib.error.HTTPError as e: |
| 126 | + if e.code != 422: # 422 == label already exists |
| 127 | + raise |
| 128 | + |
| 129 | + |
| 130 | +def upsert_issue(report, head, head_date): |
| 131 | + """Open the tracking issue, or refresh it if one is already open.""" |
| 132 | + title = f"Update Wycheproof test vectors to {head[:7]} ({head_date})" |
| 133 | + body = ( |
| 134 | + "New test vectors have landed on the " |
| 135 | + f"[{WYCHEPROOF_REPO}](https://github.com/{WYCHEPROOF_REPO}) default branch " |
| 136 | + "since the commit pinned in `test/wycheproof/wycheproof_client.py`.\n\n" |
| 137 | + f"```\n{report}\n```\n\n" |
| 138 | + "_Opened and refreshed automatically by the `Wycheproof updates` workflow._" |
| 139 | + ) |
| 140 | + ensure_label() |
| 141 | + open_issues = request( |
| 142 | + "GET", |
| 143 | + f"https://api.github.com/repos/{REPO}/issues" |
| 144 | + f"?state=open&labels={ISSUE_LABEL}&per_page=1", |
| 145 | + ) |
| 146 | + if open_issues: |
| 147 | + number = open_issues[0]["number"] |
| 148 | + request( |
| 149 | + "PATCH", |
| 150 | + f"https://api.github.com/repos/{REPO}/issues/{number}", |
| 151 | + {"title": title, "body": body}, |
| 152 | + ) |
| 153 | + print(f"Updated existing issue #{number}") |
| 154 | + else: |
| 155 | + created = request( |
| 156 | + "POST", |
| 157 | + f"https://api.github.com/repos/{REPO}/issues", |
| 158 | + {"title": title, "body": body, "labels": [ISSUE_LABEL]}, |
| 159 | + ) |
| 160 | + print(f"Opened issue #{created['number']}") |
| 161 | + |
| 162 | + |
| 163 | +def main(): |
| 164 | + report, has_updates, head, head_date = build_report() |
| 165 | + print(report) |
| 166 | + if has_updates: |
| 167 | + upsert_issue(report, head, head_date) |
| 168 | + |
| 169 | + |
| 170 | +if __name__ == "__main__": |
| 171 | + main() |
0 commit comments