Skip to content

Commit 3fc9363

Browse files
committed
[anneal][release] Add exocrate archive metadata helpers
gherrit-pr-id: Gra3t2uewe7dw5cfr6mu3gnvggplikenc
1 parent ac3faf7 commit 3fc9363

3 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright 2026 The Fuchsia Authors
4+
#
5+
# Licensed under a BSD-style license <LICENSE-BSD>, Apache License, Version 2.0
6+
# <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, or the MIT
7+
# license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
8+
# This file may not be copied, modified, or distributed except according to
9+
# those terms.
10+
11+
"""Emit JSON metadata for one published Anneal toolchain archive."""
12+
13+
import argparse
14+
import hashlib
15+
import json
16+
from pathlib import Path
17+
18+
19+
def sha256_file(path: Path) -> str:
20+
hasher = hashlib.sha256()
21+
with path.open("rb") as f:
22+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
23+
hasher.update(chunk)
24+
return hasher.hexdigest()
25+
26+
27+
def main() -> None:
28+
parser = argparse.ArgumentParser()
29+
parser.add_argument("--archive", required=True, type=Path)
30+
parser.add_argument("--target", required=True)
31+
parser.add_argument("--os", required=True)
32+
parser.add_argument("--arch", required=True)
33+
parser.add_argument("--url", required=True)
34+
parser.add_argument("--out", required=True, type=Path)
35+
args = parser.parse_args()
36+
37+
archive = args.archive.resolve()
38+
if not archive.is_file():
39+
raise SystemExit(f"archive does not exist: {archive}")
40+
41+
metadata = {
42+
"target": args.target,
43+
"os": args.os,
44+
"arch": args.arch,
45+
"filename": archive.name,
46+
"sha256": sha256_file(archive),
47+
"url": args.url,
48+
}
49+
50+
args.out.parent.mkdir(parents=True, exist_ok=True)
51+
args.out.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n")
52+
53+
54+
if __name__ == "__main__":
55+
main()
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright 2026 The Fuchsia Authors
4+
#
5+
# Licensed under a BSD-style license <LICENSE-BSD>, Apache License, Version 2.0
6+
# <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, or the MIT
7+
# license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
8+
# This file may not be copied, modified, or distributed except according to
9+
# those terms.
10+
11+
"""Unit tests for Anneal exocrate metadata helper scripts."""
12+
13+
from __future__ import annotations
14+
15+
import hashlib
16+
import importlib.util
17+
import tempfile
18+
import unittest
19+
from pathlib import Path
20+
21+
22+
TOOLS = Path(__file__).resolve().parent
23+
24+
25+
def load_script(name: str):
26+
spec = importlib.util.spec_from_file_location(name.replace("-", "_"), TOOLS / f"{name}.py")
27+
assert spec is not None and spec.loader is not None
28+
module = importlib.util.module_from_spec(spec)
29+
spec.loader.exec_module(module)
30+
return module
31+
32+
33+
collect_metadata = load_script("collect-release-archive-metadata")
34+
update_metadata = load_script("update-exocrate-metadata")
35+
36+
37+
def exocrate_section(os_name: str, arch: str, sha256: str, url: str) -> str:
38+
return f"""[package.metadata.exocrate.{os_name}.{arch}]
39+
sha256 = "{sha256}"
40+
url = "{url}"
41+
"""
42+
43+
44+
class ExocrateMetadataHelperTests(unittest.TestCase):
45+
def test_collect_archive_metadata_hashes_bytes(self) -> None:
46+
with tempfile.TemporaryDirectory() as tmp:
47+
archive = Path(tmp) / "archive.tar.zst"
48+
archive.write_bytes(b"archive contents")
49+
50+
self.assertEqual(
51+
collect_metadata.sha256_file(archive),
52+
hashlib.sha256(b"archive contents").hexdigest(),
53+
)
54+
55+
def test_update_manifest_requires_complete_platform_metadata(self) -> None:
56+
with tempfile.TemporaryDirectory() as tmp:
57+
root = Path(tmp)
58+
cargo_toml = root / "Cargo.toml"
59+
original_sha = "0" * 64
60+
cargo_toml.write_text(
61+
"[package]\nname = \"cargo-anneal\"\n\n"
62+
+ "".join(
63+
exocrate_section(os_name, arch, original_sha, f"https://example.com/{os_name}-{arch}.tar.zst")
64+
for os_name, arch in sorted(update_metadata.EXPECTED_PLATFORMS)
65+
),
66+
encoding="utf-8",
67+
)
68+
69+
metadata = {
70+
platform: {
71+
"sha256": f"{i + 1:064x}",
72+
"url": f"https://github.com/google/zerocopy/releases/download/tag/{target}.tar.zst",
73+
}
74+
for i, (platform, target) in enumerate(sorted(update_metadata.EXPECTED_TARGETS.items()))
75+
}
76+
update_metadata.update_manifest(cargo_toml, metadata)
77+
updated = cargo_toml.read_text(encoding="utf-8")
78+
79+
for platform, values in metadata.items():
80+
os_name, arch = platform
81+
self.assertIn(f"[package.metadata.exocrate.{os_name}.{arch}]", updated)
82+
self.assertIn(f'sha256 = "{values["sha256"]}"', updated)
83+
self.assertIn(f'url = "{values["url"]}"', updated)
84+
self.assertNotIn(original_sha, updated)
85+
86+
def test_load_metadata_rejects_wrong_target_duplicate_and_wrong_tag(self) -> None:
87+
with tempfile.TemporaryDirectory() as tmp:
88+
root = Path(tmp)
89+
linux = root / "linux.json"
90+
linux.write_text(
91+
"""{
92+
"target": "linux-x86_64",
93+
"os": "linux",
94+
"arch": "x86_64",
95+
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
96+
"url": "https://github.com/google/zerocopy/releases/download/tag/archive.tar.zst"
97+
}
98+
""",
99+
encoding="utf-8",
100+
)
101+
duplicate = root / "duplicate.json"
102+
duplicate.write_text(linux.read_text(encoding="utf-8"), encoding="utf-8")
103+
wrong_target = root / "wrong-target.json"
104+
wrong_target.write_text(
105+
linux.read_text(encoding="utf-8").replace("linux-x86_64", "macos-x86_64"),
106+
encoding="utf-8",
107+
)
108+
wrong_tag = root / "wrong-tag.json"
109+
wrong_tag.write_text(
110+
linux.read_text(encoding="utf-8").replace("/tag/", "/other-tag/"),
111+
encoding="utf-8",
112+
)
113+
114+
self.assertEqual(
115+
update_metadata.load_metadata([linux], expected_release_tag="tag"),
116+
{
117+
("linux", "x86_64"): {
118+
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
119+
"url": "https://github.com/google/zerocopy/releases/download/tag/archive.tar.zst",
120+
}
121+
},
122+
)
123+
with self.assertRaises(SystemExit):
124+
update_metadata.load_metadata([linux, duplicate], expected_release_tag="tag")
125+
with self.assertRaises(SystemExit):
126+
update_metadata.load_metadata([wrong_target], expected_release_tag="tag")
127+
with self.assertRaises(SystemExit):
128+
update_metadata.load_metadata([wrong_tag], expected_release_tag="tag")
129+
130+
131+
if __name__ == "__main__":
132+
unittest.main()
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright 2026 The Fuchsia Authors
4+
#
5+
# Licensed under a BSD-style license <LICENSE-BSD>, Apache License, Version 2.0
6+
# <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, or the MIT
7+
# license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
8+
# This file may not be copied, modified, or distributed except according to
9+
# those terms.
10+
11+
"""Update anneal/Cargo.toml's exocrate archive URLs and hashes."""
12+
13+
import argparse
14+
import json
15+
import re
16+
from pathlib import Path
17+
18+
EXPECTED_PLATFORMS = {
19+
("linux", "x86_64"),
20+
("linux", "aarch64"),
21+
("macos", "x86_64"),
22+
("macos", "aarch64"),
23+
}
24+
25+
EXPECTED_TARGETS = {
26+
("linux", "x86_64"): "linux-x86_64",
27+
("linux", "aarch64"): "linux-aarch64",
28+
("macos", "x86_64"): "macos-x86_64",
29+
("macos", "aarch64"): "macos-aarch64",
30+
}
31+
32+
SECTION_RE = re.compile(r"^\[package\.metadata\.exocrate\.([^.]+)\.([^\]]+)\]$")
33+
SHA_RE = re.compile(r'^\s*sha256\s*=\s*"[0-9a-fA-F]{64}"\s*$')
34+
URL_RE = re.compile(r'^\s*url\s*=\s*".*"\s*$')
35+
36+
37+
def load_metadata(
38+
paths: list[Path], expected_release_tag: str | None
39+
) -> dict[tuple[str, str], dict[str, str]]:
40+
metadata = {}
41+
for path in paths:
42+
value = json.loads(path.read_text())
43+
try:
44+
target = value["target"]
45+
os_name = value["os"]
46+
arch = value["arch"]
47+
sha256 = value["sha256"]
48+
url = value["url"]
49+
except KeyError as e:
50+
raise SystemExit(f"{path}: missing required key {e.args[0]!r}") from e
51+
if not all(isinstance(field, str) for field in (target, os_name, arch, sha256, url)):
52+
raise SystemExit(f"{path}: target, os, arch, sha256, and url must all be strings")
53+
if not re.fullmatch(r"[0-9a-f]{64}", sha256):
54+
raise SystemExit(f"{path}: sha256 must be 64 lowercase hex characters")
55+
platform = (os_name, arch)
56+
expected_target = EXPECTED_TARGETS.get(platform)
57+
if expected_target is not None and target != expected_target:
58+
raise SystemExit(
59+
f"{path}: target {target!r} does not match platform "
60+
f"{os_name}.{arch}; expected {expected_target!r}"
61+
)
62+
if expected_release_tag is not None:
63+
expected_url_component = f"/releases/download/{expected_release_tag}/"
64+
if expected_url_component not in url:
65+
raise SystemExit(
66+
f"{path}: url does not point at release tag "
67+
f"{expected_release_tag!r}: {url}"
68+
)
69+
if platform in metadata:
70+
raise SystemExit(f"duplicate metadata for {os_name}.{arch}")
71+
metadata[platform] = {"sha256": sha256, "url": url}
72+
return metadata
73+
74+
75+
def metadata_files(metadata_dir: Path) -> list[Path]:
76+
return sorted(path for path in metadata_dir.glob("*.json") if path.is_file())
77+
78+
79+
def update_manifest(cargo_toml: Path, metadata: dict[tuple[str, str], dict[str, str]]) -> None:
80+
lines = cargo_toml.read_text().splitlines()
81+
seen = set()
82+
updated = {platform: set() for platform in metadata}
83+
current_platform = None
84+
85+
for i, line in enumerate(lines):
86+
section = SECTION_RE.match(line)
87+
if section:
88+
platform = (section.group(1), section.group(2))
89+
current_platform = platform if platform in metadata else None
90+
if current_platform is not None:
91+
seen.add(current_platform)
92+
continue
93+
94+
if current_platform is None:
95+
continue
96+
97+
values = metadata[current_platform]
98+
if SHA_RE.match(line):
99+
lines[i] = f'sha256 = "{values["sha256"]}"'
100+
updated[current_platform].add("sha256")
101+
elif URL_RE.match(line):
102+
lines[i] = f'url = "{values["url"]}"'
103+
updated[current_platform].add("url")
104+
105+
missing_sections = set(metadata) - seen
106+
if missing_sections:
107+
formatted = ", ".join(f"{os_name}.{arch}" for os_name, arch in sorted(missing_sections))
108+
raise SystemExit(f"Cargo.toml is missing exocrate sections for: {formatted}")
109+
110+
incomplete = {
111+
platform: {"sha256", "url"} - fields
112+
for platform, fields in updated.items()
113+
if {"sha256", "url"} - fields
114+
}
115+
if incomplete:
116+
formatted = ", ".join(
117+
f"{os_name}.{arch} missing {','.join(sorted(fields))}"
118+
for (os_name, arch), fields in sorted(incomplete.items())
119+
)
120+
raise SystemExit(f"Cargo.toml has incomplete exocrate sections: {formatted}")
121+
122+
cargo_toml.write_text("\n".join(lines) + "\n")
123+
124+
125+
def main() -> None:
126+
parser = argparse.ArgumentParser()
127+
parser.add_argument("--cargo-toml", default="anneal/Cargo.toml", type=Path)
128+
parser.add_argument("--metadata-dir", type=Path)
129+
parser.add_argument("--metadata", action="append", default=[], type=Path)
130+
parser.add_argument("--expected-release-tag")
131+
parser.add_argument("--require-all", action="store_true")
132+
args = parser.parse_args()
133+
134+
paths = list(args.metadata)
135+
if args.metadata_dir is not None:
136+
paths.extend(metadata_files(args.metadata_dir))
137+
if not paths:
138+
raise SystemExit("no metadata JSON files provided")
139+
140+
metadata = load_metadata(paths, args.expected_release_tag)
141+
if args.require_all and set(metadata) != EXPECTED_PLATFORMS:
142+
missing = EXPECTED_PLATFORMS - set(metadata)
143+
extra = set(metadata) - EXPECTED_PLATFORMS
144+
messages = []
145+
if missing:
146+
messages.append("missing " + ", ".join(f"{os_name}.{arch}" for os_name, arch in sorted(missing)))
147+
if extra:
148+
messages.append("unexpected " + ", ".join(f"{os_name}.{arch}" for os_name, arch in sorted(extra)))
149+
raise SystemExit("; ".join(messages))
150+
151+
update_manifest(args.cargo_toml, metadata)
152+
153+
154+
if __name__ == "__main__":
155+
main()

0 commit comments

Comments
 (0)