Skip to content

Tree-sitter Upgrade Readiness #95

Tree-sitter Upgrade Readiness

Tree-sitter Upgrade Readiness #95

name: Tree-sitter Upgrade Readiness
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
# 1. Peer-dep compatibility — can each NPM-installed grammar install cleanly
# with tree-sitter@0.25.0 without --legacy-peer-deps?
# 2. Vendored grammars — each grammar in .github/vendored-grammars.json
# (c/swift/kotlin/dart/proto) is classified by its vendored ABI, read
# straight from gitnexus/vendor/<name>/src/parser.c (NOT node_modules,
# which is never populated for vendored grammars — that mismatch is why
# the report used to render bare "?" placeholders, #858).
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic.
#
# .github/vendored-grammars.json is the SHARED source of truth for the vendored
# SET + policy holds: this readiness report and grammar-update-monitor.yml both
# read it, so the two workflows can never disagree about which grammars are
# vendored. (The monitor also resolves upstreams from it; this report keeps its
# own upstream-drift coords and reads vendored ABIs from gitnexus/vendor/.)
#
# 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/scripts/test_check_tree_sitter_upgrade_readiness.py'
- '.github/vendored-grammars.json'
- '.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:
report:
name: Check upgrade readiness
runs-on: ubuntu-latest
timeout-minutes: 10
# Least privilege: rendering the report needs no write. The issue mutation
# lives in the schedule-only `upsert-issue` job below, so PR runs (incl. forks)
# never receive `issues: write` (#2187 review).
permissions:
contents: read
outputs:
report: ${{ steps.readiness.outputs.report }}
exit_code: ${{ steps.readiness.outputs.exit_code }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
with:
build: 'false'
# Guard the readiness script's logic (vendored classification, no bare "?",
# the manifest⇄vendor-dir consistency guard). Stdlib-only, so no extra deps;
# node_modules is populated by setup-gitnexus above, which the npm-path ABI
# reads need. Runs only on validation events (PR / manual), not the daily
# scheduled report.
- name: Run readiness script unit tests
if: github.event_name != 'schedule'
shell: bash
working-directory: .github/scripts
run: python3 -m unittest test_check_tree_sitter_upgrade_readiness -v
- 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"
# Unguessable per-run heredoc delimiter: the report includes the manifest's
# `hold` field, which a fork PR can edit — a fixed delimiter (e.g. DRIFT_EOF)
# in a hold value could close the heredoc early and inject $GITHUB_OUTPUT keys.
# A random hex delimiter the report cannot contain neutralizes that.
DELIM="DRIFT_EOF_$(openssl rand -hex 16)"
{
echo "report<<${DELIM}"
cat drift-report.md
echo "${DELIM}"
} >> "$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."
# Issue mutation is isolated here so `issues: write` is only ever granted on the
# scheduled run (never on PRs). Consumes the report + exit_code via job outputs.
upsert-issue:
name: Upsert tracking issue
needs: report
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Upsert tracking issue on blockers
if: needs.report.outputs.exit_code != '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
REPORT: ${{ needs.report.outputs.report }}
with:
script: |
const title = 'Tree-sitter 0.25 upgrade readiness';
const report = process.env.REPORT;
const body = 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,
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.
// The two report.match() regexes below are mirrored as
// _ISSUE_READY_RE / _ISSUE_BLOCKER_RE in
// .github/scripts/test_check_tree_sitter_upgrade_readiness.py, which is
// the ONLY place the contract is asserted against the rendered report.
// Keep all three in sync: changing the report prose means updating both
// these literals AND the test mirror, or requireMatch throws on the next
// scheduled run (the silent "?" fallback that used to hide drift is gone).
const requireMatch = (name, match) => {
if (!match) {
throw new Error(
`Could not extract ${name} from tree-sitter readiness report`,
);
}
return match;
};
const readyMatch = requireMatch(
'ready npm grammar count',
report.match(/- (\d+)\/(\d+) npm-installed grammars already accept tree-sitter@/),
);
const blockerMatch = requireMatch(
'blocker count',
report.match(/\*\*Blocked\*\* — (\d+) grammars? /),
);
const ready = readyMatch[1];
const total = readyMatch[2];
const blockers = 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 = {};
// Group 2 captures ONLY the Status cell ([^|]+? before the final
// `|$`), so change-detection fires on status transitions, not on
// unrelated cell drift (e.g. an upstream-ABI bump). Mirror this in
// _ROW_DIFF_RE in test_check_tree_sitter_upgrade_readiness.py.
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/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} npm-installed 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 runs
if: needs.report.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}`);
}