diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 292cb435d4..f3530e4d34 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,62 @@ updates: labels: - dependencies - ci + + # Gitnexus npm deps — tree-sitter grammars checked daily so we catch + # new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars + # are grouped so lockstep bumps produce a single PR. The tree-sitter + # RUNTIME is pinned — upgrade deliberately via the drift check workflow. + # See .github/scripts/check-tree-sitter-upgrade-readiness.py for + # the upgrade readiness tracker. + - package-ecosystem: npm + directory: /gitnexus + schedule: + interval: daily + open-pull-requests-limit: 10 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies + groups: + tree-sitter-grammars: + patterns: + - tree-sitter-* + exclude-patterns: + - tree-sitter + - tree-sitter-cli + ignore: + # Pin the tree-sitter runtime at 0.21.x until the drift check + # reports all grammars are peer-dep compatible with 0.25. + - dependency-name: tree-sitter + update-types: + - version-update:semver-major + - version-update:semver-minor + # tree-sitter-cli follows the runtime's version cadence. Bump when + # regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule. + - dependency-name: tree-sitter-cli + + # gitnexus-web (thin frontend client). + - package-ecosystem: npm + directory: /gitnexus-web + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies + - frontend + + # Shared types package. + - package-ecosystem: npm + directory: /gitnexus-shared + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py new file mode 100644 index 0000000000..df21533a8d --- /dev/null +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Monitor tree-sitter 0.25 upgrade readiness. + +Tracks two things Dependabot cannot see: + + 1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer + dependency on the tree-sitter runtime. We want to know when every + grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we + can upgrade without --legacy-peer-deps. + + 2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of + coder3101/tree-sitter-proto's parser.c. When upstream moves, we want + to know whether we can pick it up. + +Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily. +Runs locally too: + + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py + +Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready +and the vendored proto is in sync. Exit 1 when blockers remain (the +workflow uses this to open or update a tracking issue). + +No external deps -- stdlib only, so it runs on any vanilla runner. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import sys +import urllib.error +import urllib.request + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +GITNEXUS_DIR = REPO_ROOT / "gitnexus" +VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto" + +# ── Upgrade target ────────────────────────────────────────────────────── +# The runtime version we want to upgrade TO. Update this when the goal +# changes (e.g. once 0.25 lands and we target 0.26). +TARGET_RUNTIME = "0.25.0" +TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2]) + +# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current +# and target entries matter; extend when changing TARGET_RUNTIME. +RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = { + "0.21": (13, 14), + "0.25": (13, 15), +} + +assert TARGET_RUNTIME_MAJOR_MINOR in RUNTIME_ABI_RANGES, ( + f"RUNTIME_ABI_RANGES has no entry for {TARGET_RUNTIME_MAJOR_MINOR!r}. " + f"Add the ABI range after auditing the upstream release notes." +) + +# Grammars we use. Values are the upstream GitHub repos to check for +# unreleased ABI bumps (owner/repo, branch, parser.c path). +GRAMMARS: dict[str, tuple[str, str, str]] = { + "tree-sitter-c": ("tree-sitter/tree-sitter-c", "master", "src/parser.c"), + "tree-sitter-c-sharp": ("tree-sitter/tree-sitter-c-sharp", "master", "src/parser.c"), + "tree-sitter-cpp": ("tree-sitter/tree-sitter-cpp", "master", "src/parser.c"), + "tree-sitter-dart": ("UserNobody14/tree-sitter-dart", "master", "src/parser.c"), + "tree-sitter-go": ("tree-sitter/tree-sitter-go", "master", "src/parser.c"), + "tree-sitter-java": ("tree-sitter/tree-sitter-java", "master", "src/parser.c"), + "tree-sitter-javascript": ("tree-sitter/tree-sitter-javascript", "master", "src/parser.c"), + "tree-sitter-kotlin": ("fwcd/tree-sitter-kotlin", "main", "src/parser.c"), + "tree-sitter-php": ("tree-sitter/tree-sitter-php", "master", "php/src/parser.c"), + "tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"), + "tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"), + "tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"), + "tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"), + "tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"), +} + +UPSTREAM_PROTO_OWNER = "coder3101" +UPSTREAM_PROTO_REPO = "tree-sitter-proto" +UPSTREAM_PROTO_BRANCH = "main" + + +# ── Helpers ───────────────────────────────────────────────────────────── + +def read_current_runtime() -> str: + """Return the tree-sitter runtime version pinned in package.json (e.g. '0.21').""" + pkg = json.loads((GITNEXUS_DIR / "package.json").read_text()) + raw = pkg["dependencies"]["tree-sitter"] + match = re.search(r"(\d+)\.(\d+)", raw) + if not match: + raise SystemExit(f"could not parse tree-sitter version: {raw!r}") + return f"{match.group(1)}.{match.group(2)}" + + +def npm_view_json(pkg: str) -> dict | None: + """Fetch package metadata from the npm registry via HTTPS. + + Uses the registry API directly so we don't depend on the npm CLI + being available (it's a batch file on Windows which complicates + subprocess calls). + """ + url = f"https://registry.npmjs.org/{pkg}/latest" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError): + return None + + +def satisfies_target(peer_range: str | None, target: str) -> bool: + """Check if a semver range like '^0.22.4' or '^0.25.0' satisfies the target. + + Simple heuristic: extract the minimum version from the range and check + if target >= min. For caret ranges (^X.Y.Z), the upper bound is the + next major (for X>0) or next minor (for X==0). We check both bounds. + """ + if peer_range is None: + # No peer dep declared = no constraint = compatible. + return True + match = re.search(r"(\d+)\.(\d+)\.(\d+)", peer_range) + if not match: + return False + min_major, min_minor, min_patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + + t_match = re.search(r"(\d+)\.(\d+)\.(\d+)", target) + if not t_match: + return False + t_major, t_minor, t_patch = int(t_match.group(1)), int(t_match.group(2)), int(t_match.group(3)) + + # Target must be >= minimum. + target_tuple = (t_major, t_minor, t_patch) + min_tuple = (min_major, min_minor, min_patch) + if target_tuple < min_tuple: + return False + + # For caret ranges with major 0: ^0.X.Y allows [0.X.Y, 0.(X+1).0). + if peer_range.startswith("^") and min_major == 0: + if t_major != 0 or t_minor >= min_minor + 1: + return False + # For caret ranges with major >0: ^X.Y.Z allows [X.Y.Z, (X+1).0.0). + elif peer_range.startswith("^") and min_major > 0: + if t_major >= min_major + 1: + return False + + return True + + +_GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") + + +def fetch_text(url: str, timeout: int = 8) -> str | None: + """Fetch a URL and return its text, or None on failure. + + Adds an Authorization header for github.com URLs when GITHUB_TOKEN is + set (raises the rate limit from 60 to 5 000 requests/hour). + """ + headers: dict[str, str] = {} + if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url): + headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}" + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="ignore") + except (urllib.error.URLError, urllib.error.HTTPError): + return None + + +def extract_abi_from_text(text: str) -> int | None: + """Extract LANGUAGE_VERSION from parser.c text.""" + match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", text[:4096]) + return int(match.group(1)) if match else None + + +def extract_language_version(parser_c: pathlib.Path) -> int | None: + """Return the LANGUAGE_VERSION defined in a parser.c, or None if absent.""" + if not parser_c.is_file(): + return None + with parser_c.open("r", encoding="utf-8", errors="ignore") as fh: + head = fh.read(4096) + return extract_abi_from_text(head) + + +def md_h(text: str, level: int = 2) -> str: + return f"{'#' * level} {text}\n" + + +# ── Main ──────────────────────────────────────────────────────────────── + +def main() -> int: + blockers: dict[str, str] = {} + lines: list[str] = [] + lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1)) + lines.append("") + + current_runtime = read_current_runtime() + current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0)) + target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0)) + + lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})") + lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})") + lines.append("") + + # ── Grammar peer-dep compatibility ─────────────────────────────── + lines.append(md_h("Grammar compatibility", 2)) + lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |") + lines.append("|---|---|---|---|---|---|---|") + + ready_count = 0 + total_count = len(GRAMMARS) + + for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()): + # Fetch latest npm metadata. + info = npm_view_json(name) + fetch_failed = info is None + npm_version = "?" + peer_range = None + peer_optional = True + if info: + npm_version = info.get("version", "?") + peers = info.get("peerDependencies") or {} + peer_range = peers.get("tree-sitter") + meta = info.get("peerDependenciesMeta") or {} + ts_meta = meta.get("tree-sitter") or {} + peer_optional = ts_meta.get("optional", False) if peer_range else True + + if fetch_failed: + peer_display = "? (fetch failed)" + compatible = False + else: + peer_display = peer_range or "none" + if peer_range and not peer_optional: + peer_display += " (required)" + compatible = satisfies_target(peer_range, TARGET_RUNTIME) + + # Check installed ABI using the same parser_path from GRAMMARS. + installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path + if not installed_parser.is_file(): + # Fallback to default location. + installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c" + installed_abi = extract_language_version(installed_parser) + abi_display = str(installed_abi) if installed_abi else "?" + + # Check upstream (main/master branch) ABI for unreleased work. + upstream_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/{parser_path}" + ) + upstream_text = fetch_text(upstream_url) + upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None + upstream_abi_display = str(upstream_abi) if upstream_abi else "?" + + # Determine status. + if fetch_failed: + status = "Unknown (fetch failed)" + blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep" + elif compatible: + status = "Ready" + ready_count += 1 + elif upstream_abi and upstream_abi >= 15: + status = "Unreleased (ABI 15 on main)" + blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm" + else: + status = "Blocking" + blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25" + + # Also check upstream package.json for relaxed peer dep. + if not compatible and not fetch_failed: + upstream_pkg_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/package.json" + ) + upstream_pkg_text = fetch_text(upstream_pkg_url) + if upstream_pkg_text: + try: + upstream_pkg = json.loads(upstream_pkg_text) + upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter") + if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME): + status = "Unreleased (peer relaxed on main)" + blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm" + except json.JSONDecodeError: + pass + + compat_icon = "Yes" if compatible else "**No**" + lines.append( + f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |" + ) + + lines.append("") + lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.") + lines.append("") + + # ── Vendored proto drift ───────────────────────────────────────── + lines.append(md_h("Vendored tree-sitter-proto", 2)) + vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c") + + upstream_proto_url = ( + f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c" + ) + upstream_proto_text = fetch_text(upstream_proto_url) + upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None + + sha_url = ( + f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}" + ) + sha_text = fetch_text(sha_url) + upstream_sha = "?" + if sha_text: + try: + upstream_sha = json.loads(sha_text).get("sha", "?")[:12] + except json.JSONDecodeError: + pass + + local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c" + local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else "" + in_sync = bool( + upstream_proto_text + and local_proto_text.replace("\r\n", "\n") + == upstream_proto_text.replace("\r\n", "\n") + ) + + lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)") + lines.append(f"- Upstream ABI: **{upstream_proto_abi}**") + lines.append(f"- Vendored ABI: **{vendored_abi}**") + lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}") + + if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi: + can_upgrade = upstream_proto_abi <= target_abi_range[1] + lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})") + if can_upgrade: + lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`") + else: + lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}") + blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range" + elif not in_sync: + lines.append("- **Action:** review upstream changes; vendored copy may need updating") + blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream" + + # ── Summary ────────────────────────────────────────────────────── + lines.append("") + lines.append(md_h("Summary", 2)) + if blockers: + lines.append(f"**{len(blockers)} blocker(s) remaining:**\n") + for b in blockers.values(): + lines.append(f"- {b}") + lines.append("") + lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.") + else: + lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.") + + print("\n".join(lines)) + return 1 if blockers else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci-global-upgrade.yml b/.github/workflows/ci-global-upgrade.yml deleted file mode 100644 index e181f46ad2..0000000000 --- a/.github/workflows/ci-global-upgrade.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Global Install Upgrade Smoke - -# Catches regressions where `npm install -g gitnexus@` fails to upgrade -# cleanly over a prior global install. Prior precedent: issue #836 and PR #843's -# incomplete fix slipped past CI because no global-upgrade test existed. -# -# Reusable workflow — only callable from ci.yml. Concurrency is governed by the -# caller (ci.yml), so no `concurrency:` block here. - -on: - workflow_call: - -jobs: - global-upgrade: - name: ${{ matrix.os }} / upgrade over ${{ matrix.prior }} - strategy: - fail-fast: false - matrix: - # macOS is the reporter's platform (issue #836) and the highest-risk - # surface for npm global-install rmdir behavior. Linux and Windows - # provide cross-platform regression coverage. - os: [macos-latest, ubuntu-latest, windows-latest] - # Prior version that must be upgraded OVER. Should be a published rc - # that preceded the fix. Bump when a known-bad version changes. - prior: ['1.6.2-rc.8'] - runs-on: ${{ matrix.os }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-gitnexus - with: - build: 'false' - - - name: Install prior published version globally - run: npm install -g gitnexus@${{ matrix.prior }} - - - name: Verify prior version installed - run: gitnexus --version - - - name: Pack current branch - working-directory: gitnexus - run: npm pack - shell: bash - - - name: Compute packed tarball path - id: tarball - working-directory: gitnexus - run: | - TARBALL=$(ls gitnexus-*.tgz | head -1) - echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" - shell: bash - - - name: Upgrade over prior version (the actual regression test) - run: npm install -g "${{ steps.tarball.outputs.path }}" - shell: bash - - - name: Verify upgraded version runs - run: gitnexus --version - - - name: Verify vendor/ has no nested node_modules after install - shell: bash - run: | - # The original #836 bug was about vendor/tree-sitter-proto/node_modules/ - # blocking rmdir on upgrade. That is what the fix eliminates. A - # vendor/tree-sitter-proto/build/ directory can still appear because - # node-gyp-build compiles through the npm-created symlink; the - # contents are plain object files and .node binaries that rmdir - # handles fine, evidenced by this test getting past the upgrade step. - GLOBAL_PREFIX=$(npm root -g) - if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto" ]; then - echo "=== Contents of global vendor/tree-sitter-proto/ ===" - ls -la "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/" - if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/node_modules" ]; then - echo "::error::vendor/tree-sitter-proto/node_modules/ was created — this is the #836 hazard" - exit 1 - fi - fi - - ignore-scripts: - name: ${{ matrix.os }} / --ignore-scripts degraded mode - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-gitnexus - with: - build: 'false' - - - name: Pack current branch - working-directory: gitnexus - run: npm pack - shell: bash - - - name: Compute packed tarball path - id: tarball - working-directory: gitnexus - run: | - TARBALL=$(ls gitnexus-*.tgz | head -1) - echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" - shell: bash - - - name: Install globally with --ignore-scripts - run: npm install -g --ignore-scripts "${{ steps.tarball.outputs.path }}" - shell: bash - - - name: Verify CLI boots without postinstall (proto parsing may be unavailable) - run: gitnexus --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2eeeaab2c..cf0c6d5c5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,11 +48,6 @@ jobs: permissions: contents: read - global-upgrade: - uses: ./.github/workflows/ci-global-upgrade.yml - permissions: - contents: read - # ── Save PR metadata for the reporting workflow ───────────────── # The ci-report.yml workflow (triggered by workflow_run) needs the # PR number and job results to post a comment. We save them as an @@ -61,7 +56,7 @@ jobs: save-pr-meta: name: Save PR Metadata if: always() && github.event_name == 'pull_request' - needs: [quality, tests, e2e, global-upgrade] + needs: [quality, tests, e2e] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -72,7 +67,6 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} - GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | mkdir -p pr-meta echo "$PR_NUMBER" > pr-meta/pr_number @@ -101,7 +95,7 @@ jobs: # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, tests, e2e, global-upgrade] + needs: [quality, tests, e2e] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -112,12 +106,10 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} - GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | echo "Quality: $QUALITY" echo "Tests: $TESTS" echo "E2E: $E2E" - echo "Global upgrade: $GLOBAL_UPGRADE" if [[ "$QUALITY" != "success" ]] || [[ "$TESTS" != "success" ]]; then echo "::error::Quality or test jobs failed" @@ -127,7 +119,3 @@ jobs: echo "::error::E2E job failed" exit 1 fi - if [[ "$GLOBAL_UPGRADE" != "success" && "$GLOBAL_UPGRADE" != "skipped" ]]; then - echo "::error::Global upgrade smoke failed" - exit 1 - fi diff --git a/.github/workflows/tree-sitter-upgrade-readiness.yml b/.github/workflows/tree-sitter-upgrade-readiness.yml new file mode 100644 index 0000000000..74ce72a27f --- /dev/null +++ b/.github/workflows/tree-sitter-upgrade-readiness.yml @@ -0,0 +1,185 @@ +name: Tree-sitter Upgrade Readiness + +# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks: +# 1. Peer-dep compatibility — can each grammar install cleanly with +# tree-sitter@0.25.0 without --legacy-peer-deps? +# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved +# ahead of our vendored snapshot? +# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic. +# +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". + +on: + schedule: + # Daily at 09:00 UTC. Matches Dependabot's daily cadence so drift + # and dep PRs surface together. + - cron: '0 9 * * *' + workflow_dispatch: + pull_request: + paths: + - '.github/scripts/check-tree-sitter-upgrade-readiness.py' + - '.github/workflows/tree-sitter-upgrade-readiness.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + readiness: + name: Check upgrade readiness + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # Needed to open/update the tracking issue on scheduled runs. + issues: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup-gitnexus + with: + build: 'false' + + - name: Run upgrade readiness check + id: readiness + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md + code=$? + set -e + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + { + echo 'report<> "$GITHUB_OUTPUT" + echo "=== Report ===" + cat drift-report.md + + # On PR runs, the script validates that it runs correctly. Blockers + # are informational — the scheduled run opens a tracking issue. + - name: Annotate PR with readiness status + if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0' + run: | + echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report." + + - name: Upsert tracking issue on scheduled runs + if: > + github.event_name == 'schedule' && + steps.readiness.outputs.exit_code != '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REPORT: ${{ steps.readiness.outputs.report }} + with: + script: | + const title = 'Tree-sitter 0.25 upgrade readiness'; + const report = process.env.REPORT; + const body = report + '\n\n' + + 'Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' + + 'Closes automatically when all blockers are resolved.'; + const { data: open } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'tree-sitter-drift', + per_page: 10, + }); + const existing = open.find(i => i.title === title); + if (existing) { + // Extract ready/total count for the changelog comment. + const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/); + const blockerMatch = report.match(/\*\*(\d+) blocker/); + const ready = readyMatch ? readyMatch[1] : '?'; + const total = readyMatch ? readyMatch[2] : '?'; + const blockers = blockerMatch ? blockerMatch[1] : '?'; + + // Find grammars whose status changed by diffing the old and + // new table rows. Each row looks like: + // | `tree-sitter-foo` | ... | Ready | + // | `tree-sitter-foo` | ... | Blocking | + const parseRows = (md) => { + const map = {}; + for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) { + map[m[1]] = m[2].trim(); + } + return map; + }; + const oldRows = parseRows(existing.body || ''); + const newRows = parseRows(report); + const changes = []; + for (const [name, newStatus] of Object.entries(newRows)) { + const oldStatus = oldRows[name]; + if (oldStatus && oldStatus !== newStatus) { + changes.push(`\`${name}\`: ${oldStatus} → ${newStatus}`); + } + } + + const today = new Date().toISOString().slice(0, 10); + let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`; + if (changes.length > 0) { + comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n'); + } else { + comment += ' No changes from previous run.'; + } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body: comment, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + }); + core.info(`Updated existing issue #${existing.number}`); + } else { + const { data: created } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['tree-sitter-drift', 'dependencies'], + }); + core.info(`Opened issue #${created.number}`); + } + + - name: Close tracking issue on clean scheduled runs + if: > + github.event_name == 'schedule' && + steps.readiness.outputs.exit_code == '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const title = 'Tree-sitter 0.25 upgrade readiness'; + const { data: open } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'tree-sitter-drift', + per_page: 10, + }); + const existing = open.find(i => i.title === title); + if (existing) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.', + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + state: 'closed', + }); + core.info(`Closed issue #${existing.number}`); + }