From 109a3c694667369cdc985c0310f31fd1dca9860f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 15 Apr 2026 13:24:53 +0100 Subject: [PATCH 1/9] ci: standardize workflow concurrency and automate release-note labeling (#837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: standardize workflow concurrency and automate release-note labeling Concurrency — prevent racing CI jobs - Every top-level workflow now declares an explicit concurrency block. - PR runs cancel-in-progress on supersede; main/push/workflow_call/publish runs queue instead of cancelling so every commit and every release is validated end-to-end. - ci.yml uses a literal `CI-` prefix (not `${{ github.workflow }}`) and a per-run nested group for workflow_call invocations, avoiding a potential deadlock with publish.yml and release-candidate.yml callers whose own concurrency groups could otherwise collide with the called workflow. - ci-report.yml falls back to `/` for fork PRs (stable across reruns) instead of the per-run-unique workflow_run.id which did not actually serialize anything. - ci-quality.yml enforces the convention: fails CI if any non-reusable workflow lacks a concurrency block or a reusable workflow declares one. Release-note automation - New pr-labeler.yml: amannn/action-semantic-pull-request enforces conventional-commit PR titles on pull_request (fork-safe, read-only); release-drafter/release-drafter with disable-releaser: true applies the matching label under pull_request_target (write-scoped). sync-labels in .github/release-drafter.yml removes managed autolabels that no longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped from a PR). - .github/release.yml (unchanged) continues to map labels to categorized release-notes sections. - dependabot.yml added for the github-actions ecosystem so pinned SHAs auto-refresh on a weekly cadence. Docs - CONTRIBUTING.md documents the concurrency convention, the conventional-commit PR-title rules, and the reusable-workflow exception. Follow-up to verify before relying on the labeler in anger - gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 - gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 - Confirm release-drafter reads its config from the base ref (not fork head) when invoked via pull_request_target. * ci: address PR review feedback on concurrency and labeler workflows Two blocking fixes - pr-labeler.yml: separate concurrency slots for pull_request and pull_request_target. Previously both triggers shared a single group with cancel-in-progress: true, so the privileged autolabel run could cancel the title-validation check mid-run and leave a required status in a permanent cancelled state. - pr-labeler.yml autolabel job: add contents: read. release-drafter's context.config() reads .github/release-drafter.yml from the default branch via the repo-contents API and 403s without the scope. Job-level permissions nullify all unlisted scopes so an explicit grant is needed. Two non-blocking improvements - Replace the hardcoded reusable-workflow allowlist in ci-quality.yml with dynamic on:-block parsing. New workflow_call-only workflows no longer produce false-positive convention failures. - Implement actual group-key validation. The check now also asserts that every concurrency.group expression references either ${{ github.workflow }} or the literal CI- prefix (the documented ci.yml exception). - Script extracted to .github/scripts/check-workflow-concurrency.py so it is runnable locally and independently testable. --- .github/dependabot.yml | 16 ++ .github/release-drafter.yml | 53 ++++++ .github/scripts/check-workflow-concurrency.py | 173 ++++++++++++++++++ .github/workflows/ci-quality.yml | 23 +++ .github/workflows/ci-report.yml | 10 + .github/workflows/ci.yml | 15 +- .github/workflows/claude-code-review.yml | 3 +- .github/workflows/claude.yml | 3 +- .github/workflows/pr-description-check.yml | 3 +- .github/workflows/pr-labeler.yml | 113 ++++++++++++ .github/workflows/publish.yml | 11 +- .github/workflows/release-candidate.yml | 8 +- .github/workflows/triage-sweep.yml | 4 +- CONTRIBUTING.md | 78 +++++++- 14 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/scripts/check-workflow-concurrency.py create mode 100644 .github/workflows/pr-labeler.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..292cb435d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + # Keep third-party Actions SHA pins current. See CONTRIBUTING.md — when + # reviewing these bumps, verify the SHA corresponds to the claimed tag by + # running `gh api repos///git/refs/tags/` before merge. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + labels: + - dependencies + - ci diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000..378a60e2c --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,53 @@ +# release-drafter config — used only for PR autolabeling by +# `.github/workflows/pr-labeler.yml` (the workflow passes `disable-releaser: true`, +# so the draft-release side of release-drafter never runs). +# +# The labels applied here are the same ones `.github/release.yml` maps to +# categorized release-notes sections. +# +# `sync-labels: true` removes managed autolabels that no longer match the PR — +# critical for the breaking-change case: if a PR title drops the `!` or the body +# drops `BREAKING CHANGE:`, the `breaking` label is pulled off automatically. + +# Required by release-drafter; not used because releaser is disabled. +name-template: 'unused' +tag-template: 'unused' +template: | + $CHANGES + +sync-labels: true + +autolabeler: + - label: enhancement + title: + - '/^feat(\([^)]+\))?!?:/i' + - label: bug + title: + - '/^fix(\([^)]+\))?!?:/i' + - label: performance + title: + - '/^perf(\([^)]+\))?!?:/i' + - label: refactor + title: + - '/^refactor(\([^)]+\))?!?:/i' + - label: documentation + title: + - '/^docs(\([^)]+\))?!?:/i' + - label: test + title: + - '/^test(\([^)]+\))?!?:/i' + - label: ci + title: + - '/^ci(\([^)]+\))?!?:/i' + - label: dependencies + title: + - '/^(build|deps)(\([^)]+\))?!?:/i' + - label: chore + title: + - '/^(chore|revert)(\([^)]+\))?!?:/i' + # Breaking-change marker: either `!` in the type prefix or `BREAKING CHANGE:` in body. + - label: breaking + title: + - '/^[a-z]+(\([^)]+\))?!:/i' + body: + - '/BREAKING[ -]CHANGE:/i' diff --git a/.github/scripts/check-workflow-concurrency.py b/.github/scripts/check-workflow-concurrency.py new file mode 100644 index 000000000..300cc7f4b --- /dev/null +++ b/.github/scripts/check-workflow-concurrency.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Enforce the GitHub Actions concurrency convention. + +See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules. + +Invoked from .github/workflows/ci-quality.yml. Runs locally too: + python3 .github/scripts/check-workflow-concurrency.py .github/workflows + +Rules: + 1. Every entry-point (non-reusable) workflow declares a top-level + `concurrency:` block. + 2. Reusable workflows (on: workflow_call ONLY) do NOT declare one. + 3. The `concurrency.group` expression MUST reference either + `${{ github.workflow }}` or a literal `CI-` prefix (the documented + ci.yml reusable-workflow-safe exception). This is checked by substring + containment rather than prefix match because ci.yml's group is a + conditional expression that resolves to a `CI-…` literal at runtime. + +We deliberately do not use a YAML library — keeps the script dependency-free +on any vanilla runner. `on:` block parsing is line-based and handles both the +flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + + +REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-") + + +def is_reusable(lines: list[str]) -> bool: + """Return True iff the workflow's `on:` block names only `workflow_call`.""" + in_on = False + on_indent: int | None = None + keys: list[str] = [] + + for raw in lines: + # Skip blank lines and comments + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = len(raw) - len(raw.lstrip(" ")) + + if not in_on: + if raw.startswith("on:"): + remainder = raw[len("on:"):].strip() + if not remainder: + # `on:` followed by indented mapping on next lines + in_on = True + on_indent = indent + continue + if remainder.startswith("[") and remainder.endswith("]"): + # Flow-style list: on: [workflow_call] + items = [ + item.strip() for item in remainder.strip("[]").split(",") + ] + return items == ["workflow_call"] + # Scalar form: on: workflow_call (or a single other event) + return remainder == "workflow_call" + continue + + # Inside the `on:` block; stop when indentation returns to <= on_indent + if on_indent is not None and indent <= on_indent: + break + + # Only consider keys at on_indent + indentation step (anything deeper + # is nested config like `types:`) + if ":" not in stripped: + continue + # Heuristic: first-level event keys are those with indent == on_indent + 2 + # (the canonical step for a 2-space YAML doc). We collect all first-level + # keys by tracking the smallest indent seen inside the block. + keys.append((indent, stripped.split(":", 1)[0].strip())) + + if not keys: + return False + + # Take only the outermost-indented keys as the event list + min_indent = min(i for i, _ in keys) + events = [name for i, name in keys if i == min_indent] + return events == ["workflow_call"] + + +CONCURRENCY_RE = re.compile(r"^concurrency:\s*$") +GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$") + + +def extract_group_key(lines: list[str]) -> str | None: + """Return the `group:` value of the top-level `concurrency:` block, or None.""" + for idx, raw in enumerate(lines): + if CONCURRENCY_RE.match(raw): + # Scan forward until we leave the concurrency block (next top-level key + # is at column 0 and ends with `:`). + for follow in lines[idx + 1:]: + if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"): + break + m = GROUP_RE.match(follow) + if m: + return m.group(1).strip().strip("'").strip('"') + break + return None + + +def has_top_level_concurrency(lines: list[str]) -> bool: + return any(CONCURRENCY_RE.match(raw) for raw in lines) + + +def check(workflows_dir: pathlib.Path) -> int: + fail = 0 + files = sorted( + list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml")) + ) + for path in files: + lines = path.read_text(encoding="utf-8").splitlines() + reusable = is_reusable(lines) + has_conc = has_top_level_concurrency(lines) + + if reusable: + if has_conc: + print( + f"::error file={path}::Reusable workflow (on: workflow_call) " + "must NOT declare its own concurrency block — it inherits " + "from the caller. See CONTRIBUTING.md -> GitHub Actions — " + "Concurrency Convention." + ) + fail = 1 + continue + + if not has_conc: + print( + f"::error file={path}::Missing top-level concurrency block. " + "See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention." + ) + fail = 1 + continue + + group = extract_group_key(lines) + if group is None: + print( + f"::error file={path}::concurrency block is missing a " + "`group:` key." + ) + fail = 1 + continue + + if not any(token in group for token in REQUIRED_TOKENS): + print( + f"::error file={path}::concurrency.group `{group}` must " + f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> " + "GitHub Actions — Concurrency Convention." + ) + fail = 1 + + return fail + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + workflows_dir = pathlib.Path(argv[1]) + if not workflows_dir.is_dir(): + print(f"not a directory: {workflows_dir}", file=sys.stderr) + return 2 + return check(workflows_dir) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 9a5b9fedd..6c3132336 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -47,3 +47,26 @@ jobs: - uses: ./.github/actions/setup-gitnexus-web - run: npx tsc -b --noEmit working-directory: gitnexus-web + + # Enforces the convention documented in CONTRIBUTING.md → "GitHub Actions — + # Concurrency Convention": + # 1. Every entry-point (non-reusable) workflow declares a top-level + # `concurrency:` block. + # 2. Reusable workflows (`on: workflow_call` only) do NOT declare one — + # they inherit concurrency from the caller. + # 3. The concurrency group key starts with `${{ github.workflow }}` or + # the literal `CI-` prefix (the documented ci.yml exception for + # reusable-workflow-safe grouping). + # Reusability is detected by parsing each workflow's `on:` block, not an + # allowlist, so new reusable workflows never produce false positives. + workflow-convention: + name: Workflow concurrency convention + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Validate workflow concurrency convention + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/check-workflow-concurrency.py .github/workflows diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index 2a6e5cea8..fa5023f26 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -14,6 +14,16 @@ permissions: contents: read # needed for sparse checkout of vitest.config.ts pull-requests: write # needed to post sticky PR comment +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Serialize sticky-comment writes per PR so two rapid CI completions don't race. +# Internal PRs surface in `pull_requests[0].number`. Fork PRs leave that array empty, +# so we fall back to `/`, which is stable across +# reruns and subsequent pushes for the same fork PR (unlike `workflow_run.id` which +# is unique per run and therefore does not serialize anything). +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }} + cancel-in-progress: false + jobs: pr-report: name: PR Report diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba7184ace..1cb3576fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,20 @@ on: paths-ignore: ['**.md', 'docs/**', 'LICENSE'] workflow_call: +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is +# invoked as a reusable workflow from publish.yml and release-candidate.yml. In +# called-workflow context `github.workflow` evaluation is ambiguous across GitHub +# Actions versions, and a prefix that could resolve to the caller's name would +# share a concurrency group with the caller → deadlock. A literal prefix is +# immune. Direct `push`/`pull_request` invocations use `CI-`; invocations +# from a reusable-workflow caller fall into a per-run-unique group that never +# serializes with the caller. +# cancel-in-progress is event-aware: cancel superseded PR runs, queue every other +# event (push to main, workflow_call from publish.yml, etc.). concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} # ── Reusable workflow orchestration ───────────────────────────────── # Each concern lives in its own workflow file for maintainability: diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 82a65f844..aaf2f10ec 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -16,9 +16,10 @@ on: issue_comment: types: [created] +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Serialize per-PR to avoid racing review comments. concurrency: - group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }} cancel-in-progress: false jobs: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 407b2fcf8..dd30b72a9 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,9 +10,10 @@ on: pull_request_review: types: [submitted] +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Serialize per-PR/issue to avoid racing comments. concurrency: - group: claude-code-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }} + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }} cancel-in-progress: false jobs: diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index d722dfff3..de562fd35 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -8,8 +8,9 @@ on: permissions: pull-requests: write +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". concurrency: - group: pr-desc-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..aab2fcaef --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,113 @@ +name: PR Conventional Labeler + +# Two workflows in one file with different triggers, matched to the minimum +# privilege each needs: +# +# validate-title (on: pull_request) +# Fork-safe. Runs with the PR-head's read-only GITHUB_TOKEN. Uses +# `amannn/action-semantic-pull-request` to fail the check when the PR +# title doesn't follow the conventional-commit format. Because the +# action only reads the event payload, no fork-controlled code runs. +# +# autolabel (on: pull_request_target) +# Needs `pull-requests: write` to apply labels, so must be +# pull_request_target. Uses `release-drafter/release-drafter` with +# `disable-releaser: true` to only run the autolabeler against the +# `.github/release-drafter.yml` config from the BASE ref (release- +# drafter reads the config from the repository's default branch, NOT +# the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...` +# or a fork-test PR before merging if the repo is high-value). +# `sync-labels: true` in the config removes managed autolabels that no +# longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped). +# +# Title format: [(scope)][!]: +# Allowed types: feat, fix, perf, refactor, docs, test, ci, build, chore, revert, deps +# Trailing `!` on the type marks a breaking change. +# See CONTRIBUTING.md → "Pull request titles". + +on: + pull_request: + # Title-only changes fire `edited`. `opened` and `reopened` cover creation. + # `synchronize` (push to the PR branch) is intentionally excluded — titles + # don't change on push, so it only wastes CI minutes and broadens the + # privileged-token exposure window on the autolabel job. + types: [opened, edited, reopened] + pull_request_target: + types: [opened, edited, reopened] + +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Include `github.event_name` so `pull_request` (validate-title) and +# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot +# and therefore cannot cancel each other — a cancelled required-check would +# permanently block merge until the next title edit. +# Within each trigger the latest title edit still supersedes the prior run. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate-title: + # Fork-safe job — only runs on `pull_request` (not `pull_request_target`). + # Token is read-only; writes a commit status that branch protection can + # require before merge. + name: Validate PR title + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read + steps: + # Pinned to v5.5.3. Verify SHA via: + # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + perf + refactor + docs + test + ci + build + chore + revert + deps + requireScope: false + # Subject must be non-empty. We DO allow capitalized proper nouns + # (MCP, GitHub, API, etc.) — the old `^(?![A-Z]).+$` pattern + # rejected legitimate titles like `fix: MCP tool schema`. + subjectPattern: ^\S.{2,}$ + subjectPatternError: | + The subject "{subject}" in PR title "{title}" is invalid. + Subjects must be at least 3 characters and must not start with whitespace. + wip: false + + autolabel: + # Privileged job — runs only on `pull_request_target` so it can write labels. + # Never checks out fork code, never executes fork-controlled input; only + # reads the PR metadata (title, body, labels) and calls the GitHub API. + name: Apply conventional label + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # `contents: read` is required — release-drafter's context.config() reads + # `.github/release-drafter.yml` from the repo's default branch via the + # repo-contents API. Without it the job silently 403s and no labels are + # applied. Job-level permissions nullify all unlisted scopes, so an + # explicit grant is necessary here. + contents: read + pull-requests: write + steps: + # Pinned to v6.0.0. Verify SHA via: + # gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 + # Note: dependabot will likely propose a bump to v6.x on first run. + - uses: release-drafter/release-drafter@3f0f87098bd6b5c5b9a36d49c41d998ea58f9348 # v6.0.0 + with: + config-name: release-drafter.yml + disable-releaser: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8a0ee6ebc..f21c4a7ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,13 +7,22 @@ on: # No workflow-level permissions — scoped per job below. +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the +# same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: ci: uses: ./.github/workflows/ci.yml permissions: contents: read actions: read - pull-requests: write + # No pull-requests:write — `ci.yml`'s save-pr-meta job is gated on + # `github.event_name == 'pull_request'`, so it never runs during a + # tag-triggered publish. Least-privilege for release-critical paths. publish: needs: ci diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ed9bb1780..7b73080de 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -38,11 +38,11 @@ on: # No workflow-level permissions — scoped per job below. permissions: {} +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Serialize all runs on the same ref (push + workflow_dispatch) to prevent two publishes +# racing on the rc counter. cancel-in-progress: false — the earlier merge publishes first. concurrency: - # Serialize all runs on the same ref (push + workflow_dispatch) to prevent - # two publishes racing on the rc counter. Do not cancel an in-progress run - # when a newer one is queued — we want the earlier merge to publish first. - group: release-candidate-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: diff --git a/.github/workflows/triage-sweep.yml b/.github/workflows/triage-sweep.yml index ba5514dbf..052e2ca96 100644 --- a/.github/workflows/triage-sweep.yml +++ b/.github/workflows/triage-sweep.yml @@ -47,8 +47,10 @@ permissions: issues: write pull-requests: write +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Single global slot — newest manual dispatch supersedes any in-flight run. concurrency: - group: triage-sweep + group: ${{ github.workflow }} cancel-in-progress: true jobs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7247750f5..d2d48f017 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,18 +21,41 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro ## Branch and pull requests - Use short-lived branches off the default branch of the repo you are targeting. -- Prefer **conventional commits** (short prefix + description), for example: - - ```text - feat: add graph export option - fix: correct MCP tool schema for query - test: cover cluster merge edge case - docs: clarify analyze flags - ``` - -- **PR title:** `[area] Short description` (e.g. `[cli] Fix index refresh race`). +- **PR titles MUST follow the conventional-commit format** — `pr-labeler.yml` enforces this on every PR and auto-applies the matching label so release notes group the change correctly. - **PR description:** what changed, why, how to verify (commands), and any risk or rollback notes. +### Pull request titles + +Format: `[(scope)][!]: ` + +Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`): + +| Type | Label applied | Release-notes section | +|------|---------------|-----------------------| +| `feat` | `enhancement` | 🚀 Features | +| `fix` | `bug` | 🐛 Bug Fixes | +| `perf` | `performance` | 🏎️ Performance | +| `refactor` | `refactor` | 🔄 Refactoring | +| `test` | `test` | 🧪 Tests | +| `ci` | `ci` | 👷 CI/CD | +| `build` / `deps` | `dependencies` | 📦 Dependencies | +| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) | +| `chore` / `revert` | `chore` | (excluded from release notes) | + +Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change — the labeler then adds the `breaking` label and the 💥 Breaking Changes section is rendered first. + +Examples: + +```text +feat(web): add smart chat scroll +fix(extractors): resolve silent contract mis-resolution +perf: avoid O(n²) traversal in heritage walker +chore(deps): bump vitest to 3.0.0 +ci: standardize workflow concurrency +``` + +Commits within a PR may use any style — only the **merged PR title** shows up in release notes, so that's the one the convention applies to. + ## Before you open a PR - [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`). @@ -45,6 +68,41 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster. +## GitHub Actions — Concurrency Convention + +Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:` block using this convention: + +- **Group key** starts with `${{ github.workflow }}` so no two workflows can collide on the same group name. The discriminator that follows is chosen per event shape: + - Branch/tag scope: `${{ github.workflow }}-${{ github.ref }}` + - Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}` + - `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization). + - Global single-slot (manual dispatch utilities): `${{ github.workflow }}` + - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). + - **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel). +- **`cancel-in-progress` policy:** + + | Event | `cancel-in-progress` | Why | + |-------|----------------------|-----| + | `pull_request` CI run | `true` | New push supersedes old run | + | `push` to `main` | `false` | Every main commit gets validated | + | Tag push (`v*` publish) | `false` | Never cancel mid-publish | + | `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish | + | `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional | + | `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race | + | Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR | + | PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins | + | Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes | + +- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware: + + ```yaml + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + ``` + +- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape. + ## AI-assisted contributions If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes. From 93cdfb27fa0c726aca87e45342beadedf39036c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:36:38 +0100 Subject: [PATCH 2/9] chore(deps): bump actions/cache from 5.0.4 to 5.0.5 (#840) Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 5.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/triage-sweep.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-sweep.yml b/.github/workflows/triage-sweep.yml index 052e2ca96..43d67828d 100644 --- a/.github/workflows/triage-sweep.yml +++ b/.github/workflows/triage-sweep.yml @@ -76,7 +76,7 @@ jobs: run: pip install -r .github/scripts/triage/requirements.txt - name: Cache FastEmbed model weights - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ github.workspace }}/.fastembed_cache key: fastembed-bge-small-en-v1.5 From f3df8ab7ba2010c7445e1fecf0cab6fbc528087d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:36:45 +0100 Subject: [PATCH 3/9] chore(deps): bump dorny/paths-filter from 3.0.2 to 4.0.1 (#839) Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3.0.2 to 4.0.1. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/de90cc6fb38fc0963ad72b210f1f284cd68cea36...fbd0ab8f3e69293af611ebaee6363fc25e6d187d) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 675d9b382..027d7fdf7 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -12,7 +12,7 @@ jobs: web_changed: ${{ steps.filter.outputs.web }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3 id: filter with: filters: | From 8cb2f278cca6951e37f0c42c2651690810f1b6a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:36:48 +0100 Subject: [PATCH 4/9] chore(deps): bump actions/setup-node from 4.4.0 to 6.3.0 (#841) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...53b83947a5a98c8d113130e565377fae1a50d02f) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-quality.yml | 4 ++-- .github/workflows/publish.yml | 2 +- .github/workflows/release-candidate.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 6c3132336..b88712f80 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -9,7 +9,7 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 cache: npm @@ -22,7 +22,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 cache: npm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f21c4a7ba..d51af710a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ jobs: id-token: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 registry-url: https://registry.npmjs.org diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 7b73080de..0c37c1ac3 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -131,7 +131,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 registry-url: https://registry.npmjs.org From c2734cd25e3dc009f6b22c6a2ba9e4e51747f58f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:37:10 +0100 Subject: [PATCH 5/9] chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#838) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-e2e.yml | 2 +- .github/workflows/ci-tests.yml | 2 +- .github/workflows/ci.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 027d7fdf7..190fc9197 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -74,7 +74,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-results path: | diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6a20032c6..31b48e5c1 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -43,7 +43,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-reports path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cb3576fa..a1f3abcd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: cp pr-meta/e2e_result pr-meta/e2e-result - name: Upload PR metadata - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pr-meta path: pr-meta/ From 3fd4346bcb0b99ad68e364fd2c56599281cb21ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:52:01 +0100 Subject: [PATCH 6/9] chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#842) Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.3.1...de0fac2e4500dabe0009e67214ff5f5447ce83dd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-e2e.yml | 4 ++-- .github/workflows/ci-quality.yml | 10 +++++----- .github/workflows/ci-report.yml | 2 +- .github/workflows/ci-tests.yml | 4 ++-- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/release-candidate.yml | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 190fc9197..a017af628 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -11,7 +11,7 @@ jobs: outputs: web_changed: ${{ steps.filter.outputs.web }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3 id: filter with: @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus-web diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index b88712f80..5a0da5fd1 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus - run: npx tsc --noEmit working-directory: gitnexus @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus-web - run: npx tsc -b --noEmit working-directory: gitnexus-web @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Validate workflow concurrency convention shell: bash run: | diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index fa5023f26..a1fa33219 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -123,7 +123,7 @@ jobs: - name: Checkout (for vitest config) if: steps.meta.outputs.skip != 'true' - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: sparse-checkout: gitnexus/vitest.config.ts sparse-checkout-cone-mode: false diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 31b48e5c1..27eb75383 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 25 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' @@ -63,7 +63,7 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 25 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index aaf2f10ec..d9d9decf6 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -77,7 +77,7 @@ jobs: core.setOutput('branch', pr.head.ref); - name: Checkout PR head - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.pr.outputs.repo }} ref: ${{ steps.pr.outputs.sha }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index dd30b72a9..c4a2e9450 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -91,7 +91,7 @@ jobs: core.setOutput('branch', pr.head.ref); - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }} ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d51af710a..03898a267 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 0c37c1ac3..cc22f4749 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -62,7 +62,7 @@ jobs: should_run: ${{ steps.decide.outputs.should_run }} head_sha: ${{ steps.decide.outputs.head_sha }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true @@ -126,7 +126,7 @@ jobs: contents: write # push rc tag + marker id-token: write # npm provenance steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true From 7a5ab57bd3c48645eec536db315a9e0f6c56c655 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:10:49 +0100 Subject: [PATCH 7/9] fix: add preinstall cleanup to prevent ENOTEMPTY on global upgrade (#843) * Initial plan * fix: add preinstall cleanup for vendor/tree-sitter-proto to prevent ENOTEMPTY on upgrade When upgrading gitnexus globally, npm may fail with ENOTEMPTY because it cannot cleanly remove node_modules/ and build/ directories that a previous installation's file: dependency resolution created inside vendor/tree-sitter-proto/. Add a preinstall script that removes those leftover directories before npm resolves dependencies. Also add .npmignore entries for vendor build artifacts as a belt-and-suspenders measure. Fixes #836 Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8b7c1fdd-0c20-4cf4-a64a-9e9d1c0b20ed Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: log warnings in preinstall cleanup catch block Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8b7c1fdd-0c20-4cf4-a64a-9e9d1c0b20ed Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/.npmignore | 4 +++ gitnexus/package.json | 1 + gitnexus/scripts/preinstall-cleanup.cjs | 34 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 gitnexus/scripts/preinstall-cleanup.cjs diff --git a/gitnexus/.npmignore b/gitnexus/.npmignore index 6a4cfb118..cf403314a 100644 --- a/gitnexus/.npmignore +++ b/gitnexus/.npmignore @@ -9,6 +9,10 @@ tsconfig.json .gitignore node_modules/ +# Vendor build artifacts (created during install, not shipped) +vendor/**/node_modules +vendor/**/build + # Package lock (consumers use their own) package-lock.json diff --git a/gitnexus/package.json b/gitnexus/package.json index 6448e716a..a09ff4f41 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -46,6 +46,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "preinstall": "node scripts/preinstall-cleanup.cjs", "postinstall": "node scripts/patch-tree-sitter-swift.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" diff --git a/gitnexus/scripts/preinstall-cleanup.cjs b/gitnexus/scripts/preinstall-cleanup.cjs new file mode 100644 index 000000000..a46de8ac0 --- /dev/null +++ b/gitnexus/scripts/preinstall-cleanup.cjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Preinstall cleanup script. + * + * When upgrading gitnexus globally (`npm install -g gitnexus@`), + * npm may fail with ENOTEMPTY because it cannot cleanly remove the + * `node_modules/` and `build/` directories that a *previous* + * installation's `file:` dependency resolution created inside + * `vendor/tree-sitter-proto/`. + * + * This script runs as a `preinstall` hook — before npm resolves + * dependencies — and removes those leftover directories so npm can + * proceed without errors. + * + * See: https://github.com/abhigyanpatwari/GitNexus/issues/836 + */ +const fs = require('fs'); +const path = require('path'); + +const vendorDirs = [ + path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'node_modules'), + path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'build'), +]; + +for (const dir of vendorDirs) { + try { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } catch (err) { + // Best-effort cleanup — warn but don't fail the install. + console.warn(`[preinstall] Could not remove ${dir}:`, err.message); + } +} From eb0d9c51a0f43bab01c7e519980c54ec3898e6ad Mon Sep 17 00:00:00 2001 From: enih Date: Wed, 15 Apr 2026 23:03:43 +0800 Subject: [PATCH 8/9] fix: set env.cacheDir to user-writable location (#845) When @huggingface/transformers is installed globally (e.g. via npm install -g), it defaults its cache directory to ./node_modules/.cache inside its own install dir, which is unwritable by non-root users. This causes EACCES errors on first use when the model is downloaded: EACCES: permission denied, mkdir '/usr/lib/node_modules/gitnexus/node_modules/@huggingface/transformers/.cache' Set env.cacheDir before pipeline() is called in both embedders (CLI and MCP). Respects HF_HOME env var if set, falls back to ~/.cache/huggingface. Co-authored-by: Sisyphus --- gitnexus/src/core/embeddings/embedder.ts | 5 +++++ gitnexus/src/mcp/core/embedder.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index e2d26c9e7..d27718ce5 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -157,6 +157,11 @@ export const initEmbedder = async ( try { // Configure transformers.js environment env.allowLocalModels = false; + // Default cache to user-writable location. transformers.js defaults to + // ./node_modules/.cache inside its own install dir, which is unwritable + // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). + // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. + env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`; const isDev = process.env.NODE_ENV === 'development'; if (isDev) { diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index e53a46542..592c2bba9 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -42,6 +42,11 @@ export const initEmbedder = async (): Promise => { initPromise = (async () => { try { env.allowLocalModels = false; + // Default cache to user-writable location. transformers.js defaults to + // ./node_modules/.cache inside its own install dir, which is unwritable + // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). + // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. + env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`; console.error('GitNexus: Loading embedding model (first search may take a moment)...'); From fec06b823cd891b32fe3d94f6f51f95ca19c0cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 15 Apr 2026 17:38:47 +0100 Subject: [PATCH 9/9] fix: devendor tree-sitter-proto install lifecycle to prevent ENOTEMPTY on global upgrade (#846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: devendor tree-sitter-proto install lifecycle to fix ENOTEMPTY on global upgrade PR #843's preinstall cleanup hook cannot address the reported bug because it runs on the NEW package's staging tree, not the OLD install being removed. Issue #836 still reproduces on 1.6.2-rc.8. Root cause: vendor/tree-sitter-proto was declared as `file:` dep with its own `dependencies` and `install` script, so npm created `vendor/tree-sitter-proto/node_modules/node-addon-api/` at install time, which blocked npm's rmdir on global upgrade. Changes: - Strip `dependencies` and `install` script from the vendored sub-package's package.json so npm no longer creates a nested node_modules or runs a lifecycle script under vendor/. - Hoist `node-addon-api` and `node-gyp-build` into gitnexus optionalDependencies; npm resolves them at the consumer's top level. - Add scripts/build-tree-sitter-proto.cjs modeled on patch-tree-sitter-swift.cjs. Runs at gitnexus postinstall, best-effort: skips cleanly on missing toolchain or --ignore-scripts so non-proto functionality keeps working. - Remove scripts/preinstall-cleanup.cjs — dead code; cannot run against the old install being removed. - Keep .npmignore entries from PR #843 (tarball hygiene, still correct). - Add explicit .gitignore rules for gitnexus/vendor/**/build and gitnexus/vendor/**/node_modules (closes the repo-side hygiene gap). - Add .github/workflows/ci-global-upgrade.yml: matrix smoke test that installs the previously-published rc globally, upgrades to the packed current branch, and verifies no vendor install-time artifacts survive. Runs on macOS (reporter's platform), Linux, and Windows. Also includes an --ignore-scripts degraded-mode lane. Wired into ci.yml gate. Plan: docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md Phase 1 (this commit) addresses the reported `node_modules/node-addon-api` hazard. Phase 2 (follow-up) will migrate to prebuildify + prebuilt .node binaries in the tarball — the 2026 canonical shape for tree-sitter grammars, which eliminates the postinstall compile path entirely. Refs #836 * fix(ci): ci-global-upgrade should be reusable-only and use setup-gitnexus Three issues caught by CI on PR #846: 1. Concurrency linter rejected the `CIGU-` prefix (allowlist is `${{ github.workflow }}` or substring `CI-`). The literal-prefix guidance in ci.yml is specifically about disambiguating when reusable workflows run in nested contexts, and ci-global-upgrade doesn't need its own concurrency block at all — the caller (ci.yml) already governs concurrency for nested invocations. 2. `npm install` in gitnexus/ runs `prepare: node scripts/build.js`, which depends on gitnexus-shared/dist being built first. Other CI jobs handle this via the setup-gitnexus composite action. Use it here too (with build: 'false' — we only need the dep graph, then npm pack runs prepack which builds gitnexus itself). 3. Removed `pull_request` and `workflow_dispatch` triggers. The workflow is now pure `workflow_call` — invoked once from ci.yml via `uses:`. This avoids the duplicate-run problem where both the top-level pull_request trigger AND the nested workflow_call would fire on every PR. * fix(ci): relax vendor build/ guard and use bash shell on Windows Two fixes for ci-global-upgrade failures on PR #846: 1. The guard after the upgrade step was rejecting vendor/tree-sitter-proto/build/ in the global install. That was too strict. The original #836 bug was about vendor/tree-sitter-proto/node_modules/ specifically, not build/. The build/ directory appears because node-gyp-build compiles through the symlink npm creates at node_modules/gitnexus/node_modules/tree-sitter-proto, and its contents are plain .node, .obj, .lib files that rmdir handles without trouble. We know this empirically because the test got past the upgrade step in the run where the old vendor/node_modules was present. The guard now only flags nested node_modules, which is what the fix actually removes. 2. The Windows --ignore-scripts lane failed with ENOENT when npm tried to open the tarball. The path was computed in a bash step using $(pwd), which on Windows returns /d/a/... form, but npm install ran in the default cmd shell and received a mangled Windows path. Adding shell: bash to the install steps keeps path handling consistent. --- .github/workflows/ci-global-upgrade.yml | 113 +++++++++++++ .github/workflows/ci.yml | 22 ++- .gitignore | 5 + gitnexus/package-lock.json | 154 ++---------------- gitnexus/package.json | 5 +- gitnexus/scripts/build-tree-sitter-proto.cjs | 82 ++++++++++ gitnexus/scripts/preinstall-cleanup.cjs | 34 ---- .../vendor/tree-sitter-proto/package.json | 8 +- 8 files changed, 237 insertions(+), 186 deletions(-) create mode 100644 .github/workflows/ci-global-upgrade.yml create mode 100644 gitnexus/scripts/build-tree-sitter-proto.cjs delete mode 100644 gitnexus/scripts/preinstall-cleanup.cjs diff --git a/.github/workflows/ci-global-upgrade.yml b/.github/workflows/ci-global-upgrade.yml new file mode 100644 index 000000000..e181f46ad --- /dev/null +++ b/.github/workflows/ci-global-upgrade.yml @@ -0,0 +1,113 @@ +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 a1f3abcd6..e2eeeaab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,11 @@ 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 @@ -56,7 +61,7 @@ jobs: save-pr-meta: name: Save PR Metadata if: always() && github.event_name == 'pull_request' - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, global-upgrade] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -67,6 +72,7 @@ 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 @@ -95,7 +101,7 @@ jobs: # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, global-upgrade] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -106,10 +112,12 @@ 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 "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" @@ -119,3 +127,7 @@ 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/.gitignore b/.gitignore index 4c2df272c..b769da0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,11 @@ GitNexus.sln # Git worktrees .worktrees/ +# Vendored tree-sitter grammar build artifacts (created at install time, +# never committed). See docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md +gitnexus/vendor/**/build/ +gitnexus/vendor/**/node_modules/ + /github/scripts/triage/__pycache__/ .claude-flow/ diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bf8b5bb35..def88c93c 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -62,6 +62,8 @@ "node": ">=20.0.0" }, "optionalDependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0", "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", @@ -1226,6 +1228,12 @@ "win32" ] }, + "node_modules/@ladybugdb/core/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", @@ -4118,10 +4126,13 @@ } }, "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-api-headers": { "version": "1.8.0", @@ -5069,24 +5080,6 @@ } } }, - "node_modules/tree-sitter-c-sharp/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/tree-sitter-c/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-cli": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", @@ -5121,18 +5114,9 @@ } } }, - "node_modules/tree-sitter-cpp/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-dart": { "version": "1.0.0", - "resolved": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==", "hasInstallScript": true, "license": "ISC", @@ -5176,15 +5160,6 @@ } } }, - "node_modules/tree-sitter-go/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-java": { "version": "0.23.5", "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz", @@ -5204,15 +5179,6 @@ } } }, - "node_modules/tree-sitter-java/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-javascript": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", @@ -5232,15 +5198,6 @@ } } }, - "node_modules/tree-sitter-javascript/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-kotlin": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz", @@ -5287,15 +5244,6 @@ } } }, - "node_modules/tree-sitter-php/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-proto": { "resolved": "vendor/tree-sitter-proto", "link": true @@ -5319,15 +5267,6 @@ } } }, - "node_modules/tree-sitter-python/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-ruby": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz", @@ -5347,15 +5286,6 @@ } } }, - "node_modules/tree-sitter-ruby/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-rust": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.23.1.tgz", @@ -5375,15 +5305,6 @@ } } }, - "node_modules/tree-sitter-rust/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-swift": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz", @@ -5413,16 +5334,6 @@ "license": "ISC", "optional": true }, - "node_modules/tree-sitter-swift/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-swift/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5459,24 +5370,6 @@ } } }, - "node_modules/tree-sitter-typescript/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/tree-sitter/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5884,26 +5777,11 @@ }, "vendor/tree-sitter-proto": { "version": "0.4.1", - "hasInstallScript": true, "license": "MIT", "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - }, "peerDependencies": { "tree-sitter": ">=0.21.0" } - }, - "vendor/tree-sitter-proto/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } } } } diff --git a/gitnexus/package.json b/gitnexus/package.json index a09ff4f41..ad075041d 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -46,8 +46,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "preinstall": "node scripts/preinstall-cleanup.cjs", - "postinstall": "node scripts/patch-tree-sitter-swift.cjs", + "postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, @@ -85,6 +84,8 @@ "uuid": "^13.0.0" }, "optionalDependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0", "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", diff --git a/gitnexus/scripts/build-tree-sitter-proto.cjs b/gitnexus/scripts/build-tree-sitter-proto.cjs new file mode 100644 index 000000000..d2828d5ba --- /dev/null +++ b/gitnexus/scripts/build-tree-sitter-proto.cjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Build tree-sitter-proto native binding. + * + * Why this script exists: + * tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/ + * and declared as a `file:` optionalDependency. Previously, the vendored + * package had its own `dependencies` and `install` script, which caused + * npm to create `vendor/tree-sitter-proto/node_modules/` and + * `vendor/tree-sitter-proto/build/` during install. Those directories + * blocked `rmdir` on global-install upgrade, producing: + * + * ENOTEMPTY: directory not empty, rmdir + * '.../gitnexus/vendor/tree-sitter-proto/node_modules/node-addon-api' + * + * (See https://github.com/abhigyanpatwari/GitNexus/issues/836.) + * + * We stripped `dependencies` and the `install` script from the vendored + * package.json, hoisted `node-addon-api` and `node-gyp-build` into + * gitnexus's own optionalDependencies, and moved native compilation here. + * + * What this does: + * Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/` + * (which npm creates as a copy of vendor/tree-sitter-proto/ when + * resolving the file: dep). Build output lands in + * `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node` + * — under npm-managed territory, safe on upgrade. + * + * Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any + * precondition fails (optional dep absent, no toolchain, --ignore-scripts), + * warn and exit 0 so gitnexus install still succeeds. + */ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto'); +const bindingGyp = path.join(protoDir, 'binding.gyp'); +const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node'); + +try { + if (!fs.existsSync(bindingGyp)) { + // tree-sitter-proto is an optionalDependency; absent when install + // skipped optional deps or the file: dep was not resolved. + process.exit(0); + } + + // Skip if the native binding already exists (idempotent re-run). + if (fs.existsSync(bindingNode)) { + process.exit(0); + } + + // Pre-flight: the hoisted build deps must be resolvable. + try { + require.resolve('node-addon-api'); + require.resolve('node-gyp-build'); + } catch (resolveErr) { + console.warn( + '[tree-sitter-proto] Skipping build: hoisted build deps not resolvable (%s).', + resolveErr.message, + ); + console.warn( + '[tree-sitter-proto] Proto parsing will be unavailable. Install without --no-optional and with scripts enabled to build.', + ); + process.exit(0); + } + + console.log('[tree-sitter-proto] Building native binding...'); + execSync('npx node-gyp rebuild', { + cwd: protoDir, + stdio: 'pipe', + timeout: 180000, + }); + console.log('[tree-sitter-proto] Native binding built successfully'); +} catch (err) { + console.warn('[tree-sitter-proto] Could not build native binding:', err.message); + console.warn( + '[tree-sitter-proto] Proto (.proto) parsing will be unavailable. Non-proto gitnexus functionality is unaffected.', + ); + // Exit 0: optionalDependency failures must not fail the gitnexus install. + process.exit(0); +} diff --git a/gitnexus/scripts/preinstall-cleanup.cjs b/gitnexus/scripts/preinstall-cleanup.cjs deleted file mode 100644 index a46de8ac0..000000000 --- a/gitnexus/scripts/preinstall-cleanup.cjs +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -/** - * Preinstall cleanup script. - * - * When upgrading gitnexus globally (`npm install -g gitnexus@`), - * npm may fail with ENOTEMPTY because it cannot cleanly remove the - * `node_modules/` and `build/` directories that a *previous* - * installation's `file:` dependency resolution created inside - * `vendor/tree-sitter-proto/`. - * - * This script runs as a `preinstall` hook — before npm resolves - * dependencies — and removes those leftover directories so npm can - * proceed without errors. - * - * See: https://github.com/abhigyanpatwari/GitNexus/issues/836 - */ -const fs = require('fs'); -const path = require('path'); - -const vendorDirs = [ - path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'node_modules'), - path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'build'), -]; - -for (const dir of vendorDirs) { - try { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - } catch (err) { - // Best-effort cleanup — warn but don't fail the install. - console.warn(`[preinstall] Could not remove ${dir}:`, err.message); - } -} diff --git a/gitnexus/vendor/tree-sitter-proto/package.json b/gitnexus/vendor/tree-sitter-proto/package.json index 387f3d9bb..aea236ea3 100644 --- a/gitnexus/vendor/tree-sitter-proto/package.json +++ b/gitnexus/vendor/tree-sitter-proto/package.json @@ -5,14 +5,8 @@ "repository": "https://github.com/coder3101/tree-sitter-proto", "license": "MIT", "main": "bindings/node", - "scripts": { - "install": "node-gyp-build" - }, + "_vendoredBy": "gitnexus — build deps (node-addon-api, node-gyp-build) are hoisted into gitnexus/package.json optionalDependencies, and native compilation is performed by gitnexus/scripts/build-tree-sitter-proto.cjs at gitnexus postinstall. Do NOT re-add a dependencies block or an install script here — doing so reintroduces https://github.com/abhigyanpatwari/GitNexus/issues/836 (ENOTEMPTY on global upgrade).", "peerDependencies": { "tree-sitter": ">=0.21.0" - }, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" } }