diff --git a/.github/workflows/daily-upstream-check.yml b/.github/workflows/daily-upstream-check.yml new file mode 100644 index 0000000..7f534d5 --- /dev/null +++ b/.github/workflows/daily-upstream-check.yml @@ -0,0 +1,34 @@ +name: Daily Upstream Plugin Check + +on: + schedule: + # Run daily at 05:00 UTC + - cron: '0 5 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-plugins: + name: Audit Upstream Plugins + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Run upstream check + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python scripts/check_upstream_updates.py --report docs/upstream-audit-report.md diff --git a/scripts/check_upstream_updates.py b/scripts/check_upstream_updates.py new file mode 100644 index 0000000..6bf6b54 --- /dev/null +++ b/scripts/check_upstream_updates.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Stage 1: Audit and check active plugins in manifest.json for upstream releases & Nu version bumps. + +The audit fails closed: any upstream API error marks the entry FETCH_ERROR and the +script exits non-zero rather than reporting a possibly false "up to date" signal. + +Usage: + python scripts/check_upstream_updates.py [--manifest manifest.json] [--report report.md] [--json] +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +MANIFEST_PATH = REPO_ROOT / "manifest.json" +USER_AGENT = "numan-plugins-checker/1.0" + + +class FetchError(Exception): + """Raised when a GitHub API request fails. + + Attributes: + status_code: HTTP status code when the failure is an HTTP error response, + otherwise None. + """ + + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + + +def fetch_github_json(endpoint: str) -> dict | list: + """Fetch JSON from GitHub API with optional auth token. + + Raises: + FetchError: If the request fails or the response cannot be decoded. + """ + url = f"https://api.github.com/{endpoint.lstrip('/')}" + req = urllib.request.Request( + url, + headers={ + "User-Agent": USER_AGENT, + "Accept": "application/vnd.github.v3+json", + }, + ) + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + req.add_header("Authorization", f"Bearer {token}") + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + raise FetchError(f"HTTP {e.code} from {url}", status_code=e.code) from e + except urllib.error.URLError as e: + raise FetchError(f"network error for {url}: {e.reason}") from e + except Exception as e: + raise FetchError(f"failed to fetch {url}: {e}") from e + + +def fetch_cargo_toml_nu_dep(repo: str, ref: str = "HEAD") -> str | None: + """Fetch Cargo.toml and extract nu-plugin / nu-protocol dependency version. + + A 404 (no Cargo.toml at the repository root) is treated as "unknown" rather + than a failure; all other API errors propagate. + + Raises: + FetchError: If the request fails for a reason other than a missing file. + """ + endpoint = f"repos/{repo}/contents/Cargo.toml" + if ref != "HEAD": + endpoint += f"?ref={ref}" + try: + data = fetch_github_json(endpoint) + except FetchError as e: + if e.status_code == 404: + return None + raise + if isinstance(data, dict) and "content" in data: + content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") + # match nu-plugin = "0.115.0" or nu-plugin = { version = "0.115" } + m = re.search(r'nu-plugin\s*=\s*(?:\{[^}]*version\s*=\s*)?["\']([^"\']+)["\']', content) + if m: + return m.group(1) + # fallback to nu-protocol + m_proto = re.search(r'nu-protocol\s*=\s*(?:\{[^}]*version\s*=\s*)?["\']([^"\']+)["\']', content) + if m_proto: + return m_proto.group(1) + return None + + +def fetch_latest_tags(repo: str) -> list[dict]: + """Fetch recent tags for repository with the commit each tag points at.""" + data = fetch_github_json(f"repos/{repo}/tags?per_page=5") + tags = [] + if isinstance(data, list): + for item in data: + if not isinstance(item, dict) or "name" not in item: + continue + commit_sha = None + commit = item.get("commit") + if isinstance(commit, dict) and isinstance(commit.get("sha"), str): + commit_sha = commit["sha"] + tags.append({"name": item["name"], "commit_sha": commit_sha}) + return tags + + +def resolve_tag_commit(repo: str, tag: str) -> str | None: + """Resolve a git tag name to the commit SHA it currently points at. + + Returns: + The target commit SHA, or None when the tag no longer exists upstream + (HTTP 404). + + Raises: + FetchError: If the resolution request fails for another reason. + """ + quoted = urllib.parse.quote(tag, safe="") + try: + data = fetch_github_json(f"repos/{repo}/commits/{quoted}") + except FetchError as e: + if e.status_code == 404: + return None + raise + if isinstance(data, dict) and isinstance(data.get("sha"), str): + return data["sha"] + raise FetchError(f"unexpected response resolving {repo}@{tag}") + + +def parse_version(version: str) -> tuple[int, ...] | None: + """Parse a dotted numeric version into a 3-component comparable tuple. + + Missing components are padded with zeros so "0.115" == (0, 115, 0). + """ + m = re.match(r"(\d+(?:\.\d+){0,3})", version.strip()) + if not m: + return None + parts = [int(part) for part in m.group(1).split(".")] + return tuple((parts + [0, 0, 0])[:3]) + + +def manifest_nu_upper_bound(nu_version: str) -> tuple[int, ...] | None: + """Extract the exclusive upper bound (X.Y.Z) from a manifest nu_version range. + + e.g. '>=0.114.0 <0.115.0' -> (0, 115, 0). Falls back to parsing the whole + value when the range has no '<' constraint. + """ + for part in re.split(r"[\s,]+", nu_version): + m = re.match(r"<(\d+(?:\.\d+){0,3})", part.strip()) + if m: + return parse_version(m.group(1)) + return parse_version(nu_version) + + +def nu_needs_bump(manifest_nu: str, upstream_nu: str) -> bool: + """Return True when upstream's Cargo.toml nu dep is at or beyond the manifest's supported range.""" + bound = manifest_nu_upper_bound(manifest_nu) + upstream = parse_version(upstream_nu) + if bound is None or upstream is None: + return False + return upstream >= bound + + +def _error_result(entry: dict, error: str) -> dict: + """Build an audit result that fails closed for an upstream API error.""" + current_commit = entry.get("source_commit", "") + return { + "repo": entry["repo"], + "name": entry["name"], + "current_tag": entry.get("tag"), + "current_commit": current_commit[:7] if current_commit else "", + "current_nu": entry.get("nu_version", ""), + "latest_tags": [], + "newest_tag": None, + "cargo_nu_dep": "unknown", + "has_new_tag": False, + "nu_bump": False, + "error": error, + "status": "FETCH_ERROR", + } + + +def audit_entry(entry: dict) -> dict: + """Audit single manifest entry against upstream repository.""" + repo = entry["repo"] + name = entry["name"] + current_tag = entry.get("tag") + current_nu = entry.get("nu_version", "") + current_commit = entry.get("source_commit", "") + + try: + tags = fetch_latest_tags(repo) + cargo_nu = fetch_cargo_toml_nu_dep(repo) + except FetchError as e: + return _error_result(entry, str(e)) + + newest_tag = tags[0]["name"] if tags else None + + tag_moved = False + if current_tag: + try: + resolved = resolve_tag_commit(repo, current_tag) + except FetchError as e: + return _error_result(entry, str(e)) + if resolved is None or (current_commit and resolved.lower() != current_commit.lower()): + tag_moved = True + + has_new_tag = False + if newest_tag: + if not current_tag: + # commit-snapshot entries pin no tag; any upstream tag that does not + # already point at the snapshot commit is a re-intake candidate. + try: + resolved_newest = resolve_tag_commit(repo, newest_tag) + except FetchError as e: + return _error_result(entry, str(e)) + snapshot_matches = bool( + resolved_newest and current_commit and resolved_newest.lower() == current_commit.lower() + ) + has_new_tag = not snapshot_matches + elif newest_tag != current_tag: + has_new_tag = True + + nu_bump = bool(cargo_nu) and nu_needs_bump(current_nu, cargo_nu) + + if tag_moved: + status = "TAG_PROVENANCE_MISMATCH" + elif nu_bump and has_new_tag: + status = "READY_FOR_BUMP" + elif nu_bump: + status = "UPSTREAM_NU_BUMP_NO_TAG" + elif has_new_tag: + status = "NEW_TAG_AVAILABLE" + else: + status = "UP_TO_DATE" + + return { + "repo": repo, + "name": name, + "current_tag": current_tag, + "current_commit": current_commit[:7] if current_commit else "", + "current_nu": current_nu, + "latest_tags": [t["name"] for t in tags[:3]], + "newest_tag": newest_tag, + "cargo_nu_dep": cargo_nu or "unknown", + "has_new_tag": has_new_tag, + "nu_bump": nu_bump, + "status": status, + } + + +def _truncate(text: str, limit: int) -> str: + """Flatten and shorten a message for use inside a markdown table cell.""" + text = text.replace("\n", " ").replace("|", "/") + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def generate_markdown_report(results: list[dict]) -> str: + lines = [ + "# Upstream Plugin Audit Report", + "", + f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%SZ')}_", + "", + "| Package | Upstream Repo | Current Tag | Cargo.toml nu-dep | Latest Upstream Tags | Status |", + "| :--- | :--- | :--- | :--- | :--- | :--- |", + ] + + badges = { + "READY_FOR_BUMP": "🚀 **READY_FOR_BUMP**", + "UPSTREAM_NU_BUMP_NO_TAG": "⚡ _NU_BUMP (no tag)_", + "NEW_TAG_AVAILABLE": "🏷️ _NEW_TAG_", + "TAG_PROVENANCE_MISMATCH": "⚠️ **TAG_MOVED**", + "FETCH_ERROR": "❌ **FETCH_ERROR**", + } + + for r in results: + tags_str = ", ".join(f"`{t}`" for t in r["latest_tags"]) if r["latest_tags"] else "—" + cur_tag = f"`{r['current_tag']}`" if r["current_tag"] else "—" + status_badge = badges.get(r["status"], r["status"]) + if r.get("error"): + status_badge += f"
{_truncate(r['error'], 90)}" + + lines.append( + f"| `{r['name']}` | [{r['repo']}](https://github.com/{r['repo']}) | {cur_tag} | `{r['cargo_nu_dep']}` | {tags_str} | {status_badge} |" + ) + + lines.append("") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check active plugins for upstream updates.") + parser.add_argument("--manifest", type=Path, default=MANIFEST_PATH, help="Path to manifest.json") + parser.add_argument("--report", type=Path, default=None, help="Path to output markdown report") + parser.add_argument("--json", action="store_true", help="Output JSON results to stdout") + args = parser.parse_args() + + if not args.manifest.exists(): + print(f"Error: manifest file {args.manifest} does not exist", file=sys.stderr) + return 1 + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + active = manifest.get("active", []) + + print(f"Auditing {len(active)} active plugins from {args.manifest.name}...") + results = [] + for entry in active: + res = audit_entry(entry) + results.append(res) + print( + f" • {res['name']}: {res['status']} " + f"(Cargo nu-dep: {res['cargo_nu_dep']}, newest tag: {res['newest_tag']})" + ) + if res["status"] == "FETCH_ERROR": + print(f" {res['error']}", file=sys.stderr) + + report_md = generate_markdown_report(results) + + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(report_md, encoding="utf-8") + print(f"\nReport written to {args.report}") + + gh_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if gh_summary: + with open(gh_summary, "a", encoding="utf-8") as f: + f.write(report_md) + + updates_found = any(r["status"] in ("READY_FOR_BUMP", "NEW_TAG_AVAILABLE") for r in results) + gh_output = os.environ.get("GITHUB_OUTPUT") + if gh_output: + with open(gh_output, "a", encoding="utf-8") as f: + f.write(f"updates_found={'true' if updates_found else 'false'}\n") + + if args.json: + print(json.dumps(results, indent=2)) + + failures = sum(1 for r in results if r["status"] == "FETCH_ERROR") + if failures: + print( + f"ERROR: {failures} upstream fetch(es) failed; audit is incomplete and " + "cannot report readiness", + file=sys.stderr, + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_check_upstream_updates.py b/scripts/test_check_upstream_updates.py new file mode 100644 index 0000000..db67d8f --- /dev/null +++ b/scripts/test_check_upstream_updates.py @@ -0,0 +1,215 @@ +import unittest +from unittest import mock + +from check_upstream_updates import ( + FetchError, + audit_entry, + generate_markdown_report, + manifest_nu_upper_bound, + nu_needs_bump, + parse_version, +) + + +def make_entry(**overrides): + entry = { + "repo": "idanarye/nu_plugin_skim", + "name": "nu_plugin_skim", + "tag": "v0.29.1", + "source_commit": "b86f26023a166492a0c2aea1e2c8cf803c29f813", + "nu_version": ">=0.114.0 <0.115.0", + } + entry.update(overrides) + return entry + + +class TestCheckUpstreamUpdates(unittest.TestCase): + def test_generate_markdown_report(self): + sample_results = [ + { + "repo": "idanarye/nu_plugin_skim", + "name": "nu_plugin_skim", + "current_tag": "v0.29.1", + "current_commit": "abcdef1", + "current_nu": ">=0.114.0 <0.115.0", + "latest_tags": ["v0.29.1"], + "newest_tag": "v0.29.1", + "cargo_nu_dep": "0.115", + "has_new_tag": False, + "status": "UPSTREAM_NU_BUMP_NO_TAG", + } + ] + report = generate_markdown_report(sample_results) + self.assertIn("# Upstream Plugin Audit Report", report) + self.assertIn("nu_plugin_skim", report) + self.assertIn("idanarye/nu_plugin_skim", report) + self.assertIn("0.115", report) + + def test_parse_version_pads_components(self): + self.assertEqual(parse_version("0.115"), (0, 115, 0)) + self.assertEqual(parse_version("0.115.1"), (0, 115, 1)) + self.assertEqual(parse_version("26.1140.0"), (26, 1140, 0)) + self.assertIsNone(parse_version("not-a-version")) + + def test_manifest_nu_upper_bound(self): + self.assertEqual( + manifest_nu_upper_bound(">=0.114.0 <0.115.0"), (0, 115, 0) + ) + self.assertEqual( + manifest_nu_upper_bound(">=0.90.0 <0.91.0"), (0, 91, 0) + ) + self.assertEqual(manifest_nu_upper_bound("0.114.1"), (0, 114, 1)) + + def test_nu_needs_bump(self): + self.assertTrue(nu_needs_bump(">=0.114.0 <0.115.0", "0.115")) + self.assertTrue(nu_needs_bump(">=0.114.0 <0.115.0", "0.116.0")) + self.assertFalse(nu_needs_bump(">=0.114.0 <0.115.0", "0.114.1")) + self.assertFalse(nu_needs_bump(">=0.114.0 <0.115.0", "garbage")) + + def test_fetch_error_fails_closed(self): + entry = make_entry() + with mock.patch( + "check_upstream_updates.fetch_latest_tags", + side_effect=FetchError("rate limited"), + ): + result = audit_entry(entry) + self.assertEqual(result["status"], "FETCH_ERROR") + self.assertIn("rate limited", result["error"]) + + def test_tag_provenance_mismatch_flagged(self): + entry = make_entry() + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}] + cargo.return_value = "0.114.1" + resolve.return_value = "f" * 40 + result = audit_entry(entry) + self.assertEqual(result["status"], "TAG_PROVENANCE_MISMATCH") + + def test_tag_missing_upstream_flagged(self): + entry = make_entry() + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}] + cargo.return_value = "0.114.1" + resolve.return_value = None + result = audit_entry(entry) + self.assertEqual(result["status"], "TAG_PROVENANCE_MISMATCH") + + def test_tagged_up_to_date(self): + entry = make_entry() + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}] + cargo.return_value = "0.114" + resolve.return_value = entry["source_commit"] + result = audit_entry(entry) + self.assertEqual(result["status"], "UP_TO_DATE") + self.assertFalse(result["has_new_tag"]) + + def test_new_tag_available(self): + entry = make_entry() + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.30.0", "commit_sha": "0" * 40}] + cargo.return_value = "0.114" + resolve.return_value = entry["source_commit"] + result = audit_entry(entry) + self.assertEqual(result["status"], "NEW_TAG_AVAILABLE") + self.assertTrue(result["has_new_tag"]) + + def test_nu_bump_with_new_tag_ready_for_bump(self): + entry = make_entry() + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.30.0", "commit_sha": "0" * 40}] + cargo.return_value = "0.116" + resolve.return_value = entry["source_commit"] + result = audit_entry(entry) + self.assertEqual(result["status"], "READY_FOR_BUMP") + + def test_nu_bump_without_new_tag(self): + entry = make_entry() + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}] + cargo.return_value = "0.116" + resolve.return_value = entry["source_commit"] + result = audit_entry(entry) + self.assertEqual(result["status"], "UPSTREAM_NU_BUMP_NO_TAG") + + def test_snapshot_with_newer_tag_flagged(self): + entry = make_entry( + tag=None, + intake_mode="commit-snapshot", + source_commit="5a1ca2a5ceba60108a4ca6d45ec18d213abb5227", + ) + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.104.0", "commit_sha": "0" * 40}] + cargo.return_value = "0.105" + resolve.return_value = "f" * 40 + result = audit_entry(entry) + self.assertEqual(result["status"], "NEW_TAG_AVAILABLE") + + def test_snapshot_at_newest_tag_up_to_date(self): + entry = make_entry( + tag=None, + intake_mode="commit-snapshot", + source_commit="5a1ca2a5ceba60108a4ca6d45ec18d213abb5227", + ) + with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch( + "check_upstream_updates.fetch_cargo_toml_nu_dep" + ) as cargo, mock.patch( + "check_upstream_updates.resolve_tag_commit" + ) as resolve: + tags.return_value = [{"name": "v0.104.0", "commit_sha": "0" * 40}] + cargo.return_value = "0.104" + resolve.return_value = entry["source_commit"] + result = audit_entry(entry) + self.assertEqual(result["status"], "UP_TO_DATE") + + def test_fetch_error_reported_in_markdown(self): + results = [ + { + "repo": "owner/repo", + "name": "nu_plugin_x", + "current_tag": None, + "current_commit": "", + "current_nu": "", + "latest_tags": [], + "newest_tag": None, + "cargo_nu_dep": "unknown", + "has_new_tag": False, + "error": "HTTP 403 from https://api.github.com/...", + "status": "FETCH_ERROR", + } + ] + report = generate_markdown_report(results) + self.assertIn("FETCH_ERROR", report) + self.assertIn("HTTP 403", report) + + +if __name__ == "__main__": + unittest.main()