diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 638a8b2..c454acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,11 @@ jobs: - name: Test run: "pants test ::" + - name: Check Pyrefly version pins are current + env: + GITHUB_TOKEN: ${{ github.token }} + run: "python3 build-support/bin/generate_known_versions.py --check" + - name: Package (build the wheel + sdist) run: "pants package pants-plugins/pants_pyrefly:dist" diff --git a/CHANGELOG.md b/CHANGELOG.md index d2270f4..177b93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to `pants-pyrefly` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## 0.3.0 (unreleased) + +- Pyrefly version pins (`default_known_versions`) are now generated by + `build-support/bin/generate_known_versions.py` instead of being hand-edited: it fetches each + asset's published `.sha256` and size from the GitHub release. Bump with `--version --write`; + CI runs `--check` to fail if the committed pins drift from the release. + ## 0.2.0 (2026-07-10) - `pants pyrefly-suppress` inserts inline `# pyrefly: ignore` comments for the current errors in the diff --git a/README.md b/README.md index 6679c49..6e0a71b 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,20 @@ pants test :: # run the integration tests pants package pants-plugins/pants_pyrefly:dist # build the wheel + sdist into dist/ ``` +### Bumping the pinned Pyrefly version + +The four `default_known_versions` pins in `subsystems.py` (`|||`) +are generated, not hand-edited. To move to a new Pyrefly release: + +```bash +python3 build-support/bin/generate_known_versions.py --version --write +``` + +It reads the URL template and platform mapping straight from `subsystems.py`, fetches each asset's +published `.sha256` sidecar and size from the GitHub release, and rewrites `default_version` + the +pins. CI runs the same script with `--check` and fails if the committed pins drift from what the +release actually publishes. (Set `GITHUB_TOKEN` to avoid GitHub API rate limits.) + ## Releasing Push a `vX.Y.Z` tag. The [release workflow](.github/workflows/release.yml) builds the wheel and diff --git a/build-support/bin/BUILD b/build-support/bin/BUILD new file mode 100644 index 0000000..3de58b8 --- /dev/null +++ b/build-support/bin/BUILD @@ -0,0 +1,14 @@ +# Copyright 2026 Tague Griffith +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +# Stdlib-only maintenance scripts. Not part of the published plugin (they live outside the +# `pants_pyrefly` package), so they never enter the wheel. + +python_sources(name="bin") + +python_tests( + name="tests", + # The generator/test run outside a Pants sandbox in CI (plain `python3`); the test itself is + # pure-stdlib + pytest and needs no network. + skip_pyrefly=True, +) diff --git a/build-support/bin/generate_known_versions.py b/build-support/bin/generate_known_versions.py new file mode 100644 index 0000000..d61920e --- /dev/null +++ b/build-support/bin/generate_known_versions.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# Copyright 2026 Tague Griffith +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +"""Generate or verify the Pyrefly `default_known_versions` pins in `subsystems.py`. + +Each pin is `"|||"`. Rather than hand-computing +four SHA256/size pairs on every Pyrefly bump, this reads the plugin's own `subsystems.py` +(via `ast`, so the script and the plugin can never disagree on the version, URL template, or +platform mapping), fetches the published `.sha256` sidecars and asset sizes from the +facebook/pyrefly GitHub release, and emits the pins. + +Usage (run directly; pure stdlib, no Pants required): + + GEN=build-support/bin/generate_known_versions.py + python3 $GEN # print pins for the current version + python3 $GEN --version 1.2.0 # print pins for a specific version + python3 $GEN --write # rewrite subsystems.py in place + python3 $GEN --check # CI: fail if the committed pins are stale + +Set `GITHUB_TOKEN` (or pass `--token`) to raise the GitHub API rate limit. +""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import os +import sys +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_SUBSYSTEMS = REPO_ROOT / "pants-plugins" / "pants_pyrefly" / "subsystems.py" +RELEASE_API = "https://api.github.com/repos/facebook/pyrefly/releases/tags/{version}" + + +@dataclass +class PluginConfig: + """The Pyrefly download config parsed out of `subsystems.py`.""" + + version: str + url_template: str + platform_mapping: dict[str, str] + known_versions: list[str] + # 1-based inclusive line spans of the two assignments we rewrite with `--write`. + version_span: tuple[int, int] + known_versions_span: tuple[int, int] + + +def _find_class(tree: ast.Module, name: str) -> ast.ClassDef: + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == name: + return node + raise ValueError(f"class `{name}` not found") + + +def _assignments(class_node: ast.ClassDef) -> dict[str, ast.Assign]: + out: dict[str, ast.Assign] = {} + for node in class_node.body: + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name): + out[target.id] = node + return out + + +def parse_plugin_config(text: str) -> PluginConfig: + tree = ast.parse(text) + assigns = _assignments(_find_class(tree, "Pyrefly")) + for required in ( + "default_version", + "default_url_template", + "default_url_platform_mapping", + "default_known_versions", + ): + if required not in assigns: + raise ValueError(f"`Pyrefly.{required}` not found in subsystems.py") + + version_node = assigns["default_version"] + known_node = assigns["default_known_versions"] + return PluginConfig( + version=ast.literal_eval(version_node.value), + url_template=ast.literal_eval(assigns["default_url_template"].value), + platform_mapping=ast.literal_eval(assigns["default_url_platform_mapping"].value), + known_versions=ast.literal_eval(known_node.value), + version_span=(version_node.lineno, version_node.end_lineno or version_node.lineno), + known_versions_span=(known_node.lineno, known_node.end_lineno or known_node.lineno), + ) + + +def _get(url: str, token: str | None) -> bytes: + headers = {"User-Agent": "pants-pyrefly-known-versions"} + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request) as response: + return response.read() + + +def _asset_filename(url_template: str, version: str, url_platform: str) -> str: + return url_template.format(version=version, platform=url_platform).rsplit("/", 1)[-1] + + +def compute_known_versions(config: PluginConfig, version: str, token: str | None) -> list[str]: + """Fetch sha256 + size for each mapped platform and return the pin lines, in mapping order.""" + release = json.loads(_get(RELEASE_API.format(version=version), token)) + sizes = {asset["name"]: asset["size"] for asset in release.get("assets", [])} + downloads = { + asset["name"]: asset["browser_download_url"] for asset in release.get("assets", []) + } + + pins: list[str] = [] + for pants_platform, url_platform in config.platform_mapping.items(): + filename = _asset_filename(config.url_template, version, url_platform) + if filename not in downloads: + raise ValueError(f"release {version} has no asset named `{filename}`") + asset_url = downloads[filename] + + try: + # Cheap path: read the published `.sha256` sidecar (first token is the digest). + sha256 = _get(f"{asset_url}.sha256", token).split()[0].decode() + size = sizes[filename] + except Exception: + # Fallback: download the asset and compute both locally. + blob = _get(asset_url, token) + sha256 = hashlib.sha256(blob).hexdigest() + size = len(blob) + pins.append(f"{version}|{pants_platform}|{sha256}|{size}") + return pins + + +def render_known_versions_block(pins: list[str]) -> list[str]: + lines = [" default_known_versions = ["] + lines += [f' "{pin}",' for pin in pins] + lines.append(" ]") + return lines + + +def rewrite_subsystems(path: Path, config: PluginConfig, version: str, pins: list[str]) -> None: + lines = path.read_text().splitlines() + # Replace the later assignment first so the earlier span's line numbers stay valid. + kv_start, kv_end = config.known_versions_span + lines[kv_start - 1 : kv_end] = render_known_versions_block(pins) + v_start, v_end = config.version_span + lines[v_start - 1 : v_end] = [f' default_version = "{version}"'] + path.write_text("\n".join(lines) + "\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--subsystems", + type=Path, + default=DEFAULT_SUBSYSTEMS, + help="Path to subsystems.py (default: the plugin's).", + ) + parser.add_argument( + "--version", + default=None, + help="Pyrefly version to pin (default: the current `default_version`).", + ) + parser.add_argument("--write", action="store_true", help="Rewrite subsystems.py in place.") + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if the current pins are stale (for CI). Ignores --version.", + ) + parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"), help="GitHub API token.") + args = parser.parse_args(argv) + + config = parse_plugin_config(args.subsystems.read_text()) + + if args.check: + pins = compute_known_versions(config, config.version, args.token) + if pins != config.known_versions: + print( + f"Pyrefly pins are stale for version {config.version}.\n" + "Regenerate with `python3 build-support/bin/generate_known_versions.py --write`.\n", + file=sys.stderr, + ) + print("expected:", file=sys.stderr) + for pin in pins: + print(f" {pin}", file=sys.stderr) + print("found:", file=sys.stderr) + for pin in config.known_versions: + print(f" {pin}", file=sys.stderr) + return 1 + print(f"Pyrefly pins are current for version {config.version}.") + return 0 + + version = args.version or config.version + pins = compute_known_versions(config, version, args.token) + + if args.write: + rewrite_subsystems(args.subsystems, config, version, pins) + print(f"Wrote {len(pins)} Pyrefly {version} pin(s) to {args.subsystems}.") + return 0 + + print("\n".join(render_known_versions_block(pins))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build-support/bin/generate_known_versions_test.py b/build-support/bin/generate_known_versions_test.py new file mode 100644 index 0000000..e1d7cbb --- /dev/null +++ b/build-support/bin/generate_known_versions_test.py @@ -0,0 +1,99 @@ +# Copyright 2026 Tague Griffith +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import annotations + +import json +from pathlib import Path + +import generate_known_versions as gkv + +_SUBSYSTEMS = """\ +class Pyrefly(TemplatedExternalTool): + options_scope = "pyrefly" + + default_version = "1.1.1" + default_url_template = ( + "https://github.com/facebook/pyrefly/releases/download/{version}/pyrefly-{platform}.tar.gz" + ) + default_url_platform_mapping = { + "macos_arm64": "macos-arm64", + "linux_x86_64": "linux-x86_64-musl", + } + default_known_versions = [ + "1.1.1|macos_arm64|aaaa|111", + "1.1.1|linux_x86_64|bbbb|222", + ] + + skip = SkipOption("check") +""" + + +def test_parse_plugin_config() -> None: + config = gkv.parse_plugin_config(_SUBSYSTEMS) + assert config.version == "1.1.1" + assert config.platform_mapping == { + "macos_arm64": "macos-arm64", + "linux_x86_64": "linux-x86_64-musl", + } + assert config.known_versions == [ + "1.1.1|macos_arm64|aaaa|111", + "1.1.1|linux_x86_64|bbbb|222", + ] + + +def _fake_get(monkeypatch, version: str) -> None: + # Asset name -> (size, sha256 sidecar bytes). + assets = { + "pyrefly-macos-arm64.tar.gz": (12345, b"deadbeef pyrefly-macos-arm64.tar.gz\n"), + "pyrefly-linux-x86_64-musl.tar.gz": ( + 67890, + b"cafef00d pyrefly-linux-x86_64-musl.tar.gz\n", + ), + } + release = { + "assets": [ + {"name": name, "size": size, "browser_download_url": f"https://dl/{name}"} + for name, (size, _sha) in assets.items() + ] + } + + def fake_get(url: str, token: str | None) -> bytes: + if url == gkv.RELEASE_API.format(version=version): + return json.dumps(release).encode() + for name, (_size, sha) in assets.items(): + if url == f"https://dl/{name}.sha256": + return sha + raise AssertionError(f"unexpected url {url}") + + monkeypatch.setattr(gkv, "_get", fake_get) + + +def test_compute_known_versions(monkeypatch) -> None: + _fake_get(monkeypatch, "1.1.1") + config = gkv.parse_plugin_config(_SUBSYSTEMS) + pins = gkv.compute_known_versions(config, "1.1.1", token=None) + # Emitted in platform-mapping order, sha256 from the sidecar, size from the release API. + assert pins == [ + "1.1.1|macos_arm64|deadbeef|12345", + "1.1.1|linux_x86_64|cafef00d|67890", + ] + + +def test_write_updates_version_and_pins(monkeypatch, tmp_path: Path) -> None: + _fake_get(monkeypatch, "2.0.0") + path = tmp_path / "subsystems.py" + path.write_text(_SUBSYSTEMS) + config = gkv.parse_plugin_config(path.read_text()) + pins = gkv.compute_known_versions(config, "2.0.0", token=None) + gkv.rewrite_subsystems(path, config, "2.0.0", pins) + + # The rewrite is parseable and reflects the new version + pins. + reparsed = gkv.parse_plugin_config(path.read_text()) + assert reparsed.version == "2.0.0" + assert reparsed.known_versions == [ + "2.0.0|macos_arm64|deadbeef|12345", + "2.0.0|linux_x86_64|cafef00d|67890", + ] + # Surrounding lines are preserved. + assert 'skip = SkipOption("check")' in path.read_text() diff --git a/pants.toml b/pants.toml index 26194e4..f6d42cd 100644 --- a/pants.toml +++ b/pants.toml @@ -35,4 +35,5 @@ indexes = [ root_patterns = [ "/pants-plugins", "/testprojects/src", + "/build-support/bin", ]