#!/usr/bin/env python3 # SPDX-FileCopyrightText: 2025-2026 Kat Suricata # SPDX-License-Identifier: (CC-BY-4.0+ OR Apache-2.0+) """Builds the release files into `.release/dist/`. The release pipeline runs this, and so can you, before a tag exists: python3 scripts/make-archives.py --version 1.3.1 --check-only python3 scripts/make-archives.py --version 1.3.1 Two rules come from the Tangled pipeline and are enforced here: * Every published file carries an extension. Pages maps a path without one to a directory and then answers 404, so `SHA256SUMS` is `SHA256SUMS.txt`. * The version in the specification, the version in `package.json`, and the version on the tag must agree. The site's version badge reads the specification, so a mismatch ships a release that reports the wrong number. The archives carry no timestamps from the machine that built them, so the same commit produces the same file contents in the same order. The compressed bytes can still differ between machines, because a deflate implementation is free to vary between zlib versions: the release published from v1.3.0 matches a local rebuild entry for entry and CRC for CRC, but its two archives are not byte-identical. Compare the entries, or compare a download against the checksums published beside it. """ from __future__ import annotations import argparse import hashlib import json import pathlib import re import sys import zipfile ROOT = pathlib.Path(__file__).resolve().parent.parent SPEC = ROOT / 'Digital Rust Syntax Highlighting Specification.md' UI_SPEC = ROOT / 'Digital Rust UI Specification.md' PORTS = ROOT / 'static' / 'ports' SKILL_ZIP = PORTS / 'agentskill.zip' DIST = ROOT / '.release' / 'dist' # A zip entry needs a date. A fixed one keeps the archive reproducible, and the # release date is on the pages index where a reader looks for it. FIXED_DATE = (1980, 1, 1, 0, 0, 0) def spec_version() -> str: """The first version history entry in the syntax specification.""" match = re.search(r'^- \*\*v(\d+\.\d+\.\d+)\*\*', SPEC.read_text(), re.MULTILINE) if not match: sys.exit(f'no version line in {SPEC.name}; the release cannot be named') return match.group(1) def package_version() -> str: return json.loads((ROOT / 'package.json').read_text())['version'] def check_versions(version: str) -> None: problems = [] if spec_version() != version: problems.append(f'the specification says v{spec_version()}') if package_version() != version: problems.append(f'package.json says {package_version()}') if problems: sys.exit(f'version {version} does not match: ' + '; '.join(problems)) def write_zip(path: pathlib.Path, entries: list[pathlib.Path]) -> None: """Writes a zip whose entries are sorted, so the bytes do not move.""" with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive: for source in sorted(entries): relative = source.relative_to(ROOT / 'static') info = zipfile.ZipInfo(str(relative), date_time=FIXED_DATE) info.compress_type = zipfile.ZIP_DEFLATED info.create_system = 3 # unix, so the container fields do not follow the platform info.external_attr = 0o644 << 16 archive.writestr(info, source.read_bytes()) def build(version: str) -> list[pathlib.Path]: DIST.mkdir(parents=True, exist_ok=True) for stale in DIST.iterdir(): stale.unlink() # The ports, one file per tool. The agent skill ships as its own archive # because it is installed as a directory, not read in place. ports = [p for p in PORTS.rglob('*') if p.is_file() and p != SKILL_ZIP] ports_zip = DIST / f'digital-rust-ports-v{version}.zip' write_zip(ports_zip, ports) skill_zip = DIST / f'digital-rust-agentskill-v{version}.zip' skill_zip.write_bytes(SKILL_ZIP.read_bytes()) # The specifications are the canonical documents, and the release is where a # reader goes to get them without a git client. specs = [ (SPEC, DIST / 'digital-rust-syntax-highlighting-specification.md'), (UI_SPEC, DIST / 'digital-rust-ui-specification.md'), ] for source, target in specs: target.write_bytes(source.read_bytes()) published = sorted([ports_zip, skill_zip, *(target for _, target in specs)]) lines = [ f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}' for path in published ] (DIST / 'SHA256SUMS.txt').write_text('\n'.join(lines) + '\n') return [*published, DIST / 'SHA256SUMS.txt'] def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--version', required=True, help='release version, without the v') parser.add_argument( '--check-only', action='store_true', help='check the versions and stop, for a run before the tag exists' ) args = parser.parse_args() check_versions(args.version) if args.check_only: print(f'version {args.version} agrees in the specification and package.json') return for path in build(args.version): print(f'{path.relative_to(ROOT)} {path.stat().st_size} bytes') if __name__ == '__main__': main()