refactor(deps): rewrite drift check as tree-sitter 0.25 upgrade readiness monitor

Replace the ABI drift pass/fail gate with a daily upgrade readiness
dashboard that tracks peer-dep compatibility of all 14 grammars with
tree-sitter@0.25.0 and reports which are ready, unreleased, or blocking.

Key changes:
- Rename drift-check → upgrade-readiness (script, workflow, job id)
- Fix P0: pass report via env var, not ${{ }} template interpolation
- Fix P1: npm fetch failure now adds a blocker instead of false-green
- Fix P1: pass GITHUB_TOKEN for authenticated GitHub API calls
- Switch Dependabot to daily for tree-sitter grammars
- Use dict for blockers (no prefix collision), derive TARGET_RUNTIME
  constant, reuse GRAMMARS parser_path, normalize CRLF in comparisons
- Reduce per-call HTTP timeout from 15s to 8s for workflow budget
- PR runs warn on blockers instead of hard-failing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-04-16 07:42:46 +01:00
parent 7b49df99ff
commit cedef11890
4 changed files with 403 additions and 284 deletions

View file

@ -15,15 +15,16 @@ updates:
- dependencies
- ci
# Gitnexus npm deps. Tree-sitter grammar bumps are grouped so we don't get
# one PR per grammar every time the ecosystem moves in lockstep. The
# tree-sitter RUNTIME is explicitly pinned because upgrading it changes
# which grammar ABIs load — upgrade deliberately, not automatically. See
# .github/scripts/check-tree-sitter-drift.py for the ABI invariant.
# 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: weekly
interval: daily
open-pull-requests-limit: 10
commit-message:
prefix: chore(deps)
@ -38,10 +39,8 @@ updates:
- tree-sitter
- tree-sitter-cli
ignore:
# Pin the tree-sitter runtime. Bumping 0.21 -> 0.22+ requires
# coordinated grammar updates and an ABI review (the vendored
# tree-sitter-proto is regenerated against a specific cli version).
# Do this by hand.
# 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

View file

@ -1,243 +0,0 @@
#!/usr/bin/env python3
"""Detect tree-sitter ecosystem drift that Dependabot cannot see.
Two invariants that Dependabot does not enforce:
1. ABI consistency. The tree-sitter runtime we pin (gitnexus/package.json
-> dependencies."tree-sitter") supports a known range of grammar ABIs.
Every grammar we load must ship a parser.c whose LANGUAGE_VERSION falls
inside that range. If a grammar bumps to an ABI the runtime cannot
load, the grammar silently fails at require() time -- fallback paths
kick in and test coverage can mask the degradation.
2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of
coder3101/tree-sitter-proto's parser.c, regenerated against a specific
tree-sitter-cli version. Upstream keeps moving. If upstream ships a
grammar fix we care about, or cuts a new release, we want a human to
notice -- not for the vendored copy to silently rot.
Invoked from .github/workflows/tree-sitter-drift-check.yml on a weekly
schedule. Runs locally too:
python3 .github/scripts/check-tree-sitter-drift.py
Outputs Markdown to stdout. Exit 0 when everything is in range and upstream
matches. Exit 1 when drift is detected (the workflow uses this to open or
update an issue).
No external deps -- stdlib only, so it runs on any vanilla runner.
"""
from __future__ import annotations
import json
import pathlib
import re
import subprocess
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"
# Tree-sitter runtime -> (min_abi, max_abi) it can load. Extend when we bump
# the runtime and audit the new supported range from the runtime's release
# notes. Source of truth: tree-sitter/tree-sitter runtime release notes.
RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = {
"0.21": (13, 14),
"0.22": (13, 14),
"0.23": (13, 14),
"0.24": (13, 14),
"0.25": (13, 15),
}
UPSTREAM_OWNER = "coder3101"
UPSTREAM_REPO = "tree-sitter-proto"
UPSTREAM_BRANCH = "main"
def read_runtime_minor() -> str:
"""Return the tree-sitter runtime minor series we pin (e.g. '0.21')."""
pkg = json.loads((GITNEXUS_DIR / "package.json").read_text())
raw = pkg["dependencies"]["tree-sitter"]
# Strip semver prefixes (^, ~, >=, etc.) and trailing qualifiers.
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 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
# Scan only the first few KB; LANGUAGE_VERSION lives near the top.
with parser_c.open("r", encoding="utf-8", errors="ignore") as fh:
head = fh.read(4096)
match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", head)
return int(match.group(1)) if match else None
def installed_grammars() -> list[pathlib.Path]:
"""Return parser.c paths for every installed tree-sitter-* grammar."""
nm = GITNEXUS_DIR / "node_modules"
if not nm.is_dir():
return []
out: list[pathlib.Path] = []
for entry in sorted(nm.iterdir()):
if not entry.name.startswith("tree-sitter-"):
continue
# Skip the runtime itself and the CLI; they have no parser.c.
if entry.name in ("tree-sitter-cli",):
continue
parser_c = entry / "src" / "parser.c"
if parser_c.is_file():
out.append(parser_c)
return out
def fetch_upstream_parser_c() -> str | None:
"""Fetch coder3101/tree-sitter-proto's current parser.c as text."""
url = (
f"https://raw.githubusercontent.com/{UPSTREAM_OWNER}/"
f"{UPSTREAM_REPO}/{UPSTREAM_BRANCH}/src/parser.c"
)
try:
with urllib.request.urlopen(url, timeout=20) as resp:
return resp.read().decode("utf-8", errors="ignore")
except (urllib.error.URLError, urllib.error.HTTPError) as exc:
print(f"WARN: could not fetch upstream parser.c: {exc}", file=sys.stderr)
return None
def fetch_upstream_head_sha() -> str | None:
"""Return the short SHA of coder3101/tree-sitter-proto HEAD on main."""
url = (
f"https://api.github.com/repos/{UPSTREAM_OWNER}/"
f"{UPSTREAM_REPO}/commits/{UPSTREAM_BRANCH}"
)
try:
with urllib.request.urlopen(url, timeout=20) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data.get("sha", "")[:12] or None
except (urllib.error.URLError, urllib.error.HTTPError) as exc:
print(f"WARN: could not fetch upstream HEAD: {exc}", file=sys.stderr)
return None
def md_h(text: str, level: int = 2) -> str:
return f"{'#' * level} {text}\n"
def main() -> int:
drift_found = False
lines: list[str] = []
lines.append(md_h("Tree-sitter ecosystem drift report", 1))
lines.append("")
# --- ABI consistency --------------------------------------------------
runtime_minor = read_runtime_minor()
lines.append(md_h(f"Runtime: tree-sitter {runtime_minor}.x", 2))
if runtime_minor not in RUNTIME_ABI_RANGES:
lines.append(
f"- UNKNOWN runtime ABI range for `{runtime_minor}`. "
"Add it to `RUNTIME_ABI_RANGES` in this script after auditing "
"the upstream release notes.\n"
)
drift_found = True
abi_min, abi_max = (0, 0)
else:
abi_min, abi_max = RUNTIME_ABI_RANGES[runtime_minor]
lines.append(f"- Supported grammar ABI range: **{abi_min}..{abi_max}**\n")
grammars = installed_grammars()
if not grammars:
lines.append(
"- No installed grammars found under `gitnexus/node_modules/`. "
"Run `npm install` in `gitnexus/` before checking ABI consistency.\n"
)
else:
lines.append(md_h("Installed grammar ABIs", 3))
lines.append("| Grammar | ABI | In range? |")
lines.append("|---|---|---|")
for parser_c in grammars:
name = parser_c.parents[1].name
abi = extract_language_version(parser_c)
if abi is None:
lines.append(f"| `{name}` | (no LANGUAGE_VERSION found) | [warn] |")
drift_found = True
continue
in_range = abi_min <= abi <= abi_max
lines.append(
f"| `{name}` | {abi} | {'[ok]' if in_range else '[FAIL] OUT OF RANGE'} |"
)
if not in_range:
drift_found = True
# Vendored proto must also be in range.
vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c")
lines.append("")
lines.append(md_h("Vendored tree-sitter-proto", 3))
if vendored_abi is None:
lines.append("- [warn] Could not read vendored parser.c LANGUAGE_VERSION.\n")
drift_found = True
else:
in_range = abi_min <= vendored_abi <= abi_max
lines.append(
f"- Vendored ABI: **{vendored_abi}** -- "
f"{'in range [ok]' if in_range else '**OUT OF RANGE** [FAIL]'}"
)
if not in_range:
drift_found = True
# --- Upstream drift ---------------------------------------------------
lines.append("")
lines.append(md_h("Upstream drift check", 2))
upstream_parser_c = fetch_upstream_parser_c()
if upstream_parser_c is None:
lines.append("- [warn] Could not fetch upstream parser.c; skipping drift check.\n")
else:
upstream_abi_match = re.search(
r"#define\s+LANGUAGE_VERSION\s+(\d+)", upstream_parser_c[:4096]
)
upstream_abi = int(upstream_abi_match.group(1)) if upstream_abi_match else None
upstream_size = len(upstream_parser_c)
local_path = VENDOR_PROTO_DIR / "src" / "parser.c"
local_parser_c = local_path.read_text(encoding="utf-8", errors="ignore") if local_path.is_file() else ""
local_size = len(local_parser_c)
size_match = local_size == upstream_size and local_parser_c == upstream_parser_c
upstream_sha = fetch_upstream_head_sha() or "?"
lines.append(f"- Upstream: `{UPSTREAM_OWNER}/{UPSTREAM_REPO}@{UPSTREAM_BRANCH}` (HEAD `{upstream_sha}`)")
lines.append(f"- Upstream ABI: **{upstream_abi}**")
lines.append(f"- Vendored ABI: **{vendored_abi}**")
lines.append(
f"- parser.c identical to upstream? "
f"{'yes [ok]' if size_match else 'no -- upstream has moved'}"
)
if not size_match:
lines.append("")
lines.append(
"**Action:** review the upstream changes. If the vendored ABI "
"is still compatible with our runtime AND the change is worth "
"picking up, regenerate `vendor/tree-sitter-proto/src/parser.c` "
"via `tree-sitter-cli <version-that-emits-ABI-14>` against "
f"upstream `{upstream_sha}` and update the description in "
"`vendor/tree-sitter-proto/package.json`. If upstream's ABI is "
"now outside our runtime range, document the skip and wait for "
"the tree-sitter runtime upgrade."
)
drift_found = True
lines.append("")
lines.append(md_h("Result", 2))
lines.append(f"- Drift detected: **{'yes' if drift_found else 'no'}**")
print("\n".join(lines))
return 1 if drift_found else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -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())

View file

@ -1,24 +1,24 @@
name: Tree-sitter Drift Check
name: Tree-sitter Upgrade Readiness
# Catches drift Dependabot cannot see:
# 1. ABI-range consistency between the tree-sitter runtime and every
# installed grammar (including the vendored tree-sitter-proto).
# 2. Upstream divergence between vendor/tree-sitter-proto/src/parser.c
# and coder3101/tree-sitter-proto main.
# See .github/scripts/check-tree-sitter-drift.py for the invariants.
# 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:
# Mondays at 09:00 UTC. Weekly to match Dependabot's cadence so any
# drift shows up alongside the week's dep PRs.
- cron: '0 9 * * 1'
# 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-drift.py'
- '.github/workflows/tree-sitter-drift-check.yml'
- '.github/scripts/check-tree-sitter-upgrade-readiness.py'
- '.github/workflows/tree-sitter-upgrade-readiness.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -28,8 +28,8 @@ permissions:
contents: read
jobs:
drift:
name: Report tree-sitter drift
readiness:
name: Check upgrade readiness
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@ -43,12 +43,14 @@ jobs:
with:
build: 'false'
- name: Run drift check
id: drift
- name: Run upgrade readiness check
id: readiness
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
python3 .github/scripts/check-tree-sitter-drift.py > drift-report.md
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md
code=$?
set -e
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
@ -60,23 +62,26 @@ jobs:
echo "=== Report ==="
cat drift-report.md
- name: Fail PR-triggered runs on drift
if: github.event_name == 'pull_request' && steps.drift.outputs.exit_code != '0'
# 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 "::error::Tree-sitter drift detected. See job output."
exit 1
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.drift.outputs.exit_code != '0'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
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 ecosystem drift detected';
const body = `${{ steps.drift.outputs.report }}\n\n` +
'<sub>Generated by `.github/workflows/tree-sitter-drift-check.yml`. ' +
'Re-runs weekly; closes automatically when the next clean run posts.</sub>';
const title = 'Tree-sitter 0.25 upgrade readiness';
const body = process.env.REPORT + '\n\n' +
'<sub>Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' +
'Closes automatically when all blockers are resolved.</sub>';
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
@ -107,11 +112,11 @@ jobs:
- name: Close tracking issue on clean scheduled runs
if: >
github.event_name == 'schedule' &&
steps.drift.outputs.exit_code == '0'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
steps.readiness.outputs.exit_code == '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = 'Tree-sitter ecosystem drift detected';
const title = 'Tree-sitter 0.25 upgrade readiness';
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
@ -125,7 +130,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body: 'Latest scheduled drift check came back clean. Closing automatically.',
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,