-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ci): add daily upstream plugin audit workflow and checker tool #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Regex for nu-plugin dep won't match nested inline tables in Cargo.toml
Reply with |
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Race condition - When Reply with |
||
| 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"<br><small>{_truncate(r['error'], 90)}</small>" | ||
|
|
||
| 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()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION: Generated report is written but never committed
The run step writes
docs/upstream-audit-report.md, but the workflow has no git commit/push step. If the report is meant to be persisted in the repo, add a step to configure git and commit the file. Otherwise, document that the report is step-summary-only.Reply with
@kilocode-bot fix itto have Kilo Code address this issue.