Skip to content

Commit 0a09499

Browse files
hanno-beckermkannwischer
authored andcommitted
CI: Track upstream Wycheproof ML-KEM vector updates
Add a daily workflow and helper that compare the pinned Wycheproof commit against upstream main, restricted to testvectors_v1/mlkem_* files, and open or refresh a tracking issue when they differ. Also pin wycheproof_client.py to a specific commit with a per-commit cache directory, since the diffing mechanism needs a pin to compare against. Ports pq-code-package/mldsa-native#1286. Resolves #1764 Signed-off-by: Hanno Becker <beckphan@amazon.co.uk>
1 parent 668f705 commit 0a09499

3 files changed

Lines changed: 207 additions & 3 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright (c) The mlkem-native project authors
2+
# SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT
3+
4+
name: Wycheproof updates
5+
permissions:
6+
contents: read
7+
on:
8+
schedule:
9+
# Run daily at 04:00 UTC
10+
- cron: '0 4 * * *'
11+
workflow_dispatch:
12+
13+
jobs:
14+
check:
15+
name: Check for upstream vector updates
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: read
19+
issues: write
20+
env:
21+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22+
steps:
23+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
24+
- name: Check for updates and open a tracking issue if any
25+
run: python3 .github/scripts/check-wycheproof-updates.py

test/wycheproof/wycheproof_client.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
exec_prefix = os.environ.get("EXEC_WRAPPER", "")
1818
exec_prefix = exec_prefix.split(" ") if exec_prefix != "" else []
1919

20+
# Pinned to a specific commit (2026-07-07).
21+
WYCHEPROOF_COMMIT = "fc24cd5b787d8e496bff31b0468af693a652b0f2"
2022
WYCHEPROOF_BASE_URL = (
21-
"https://raw.githubusercontent.com/C2SP/wycheproof/main/testvectors_v1"
23+
"https://raw.githubusercontent.com/C2SP/wycheproof"
24+
f"/{WYCHEPROOF_COMMIT}/testvectors_v1"
2225
)
2326

2427
WYCHEPROOF_FILES = [
@@ -51,6 +54,10 @@ def info(msg, **kwargs):
5154
print(msg, **kwargs)
5255

5356

57+
def get_wycheproof_data_dir(data_dir):
58+
return Path(data_dir) / WYCHEPROOF_COMMIT
59+
60+
5461
def download_wycheproof_files(data_dir):
5562
"""Download Wycheproof test vector files if not present."""
5663
data_dir = Path(data_dir)
@@ -276,7 +283,8 @@ def run_all(data_dir):
276283
info("ALL GOOD!")
277284
else:
278285
# Download and run all
279-
if not download_wycheproof_files(args.data_dir):
286+
data_dir = get_wycheproof_data_dir(args.data_dir)
287+
if not download_wycheproof_files(data_dir):
280288
err("Failed to download Wycheproof test files")
281289
sys.exit(1)
282-
run_all(args.data_dir)
290+
run_all(data_dir)

0 commit comments

Comments
 (0)