diff --git a/.github/labeler-area.yml b/.github/labeler-area.yml index 9afb49172af..0eab34eafb6 100644 --- a/.github/labeler-area.yml +++ b/.github/labeler-area.yml @@ -37,6 +37,16 @@ A-format: - "protos/**" - "docs/src/format/**" +# Drives the format-spec vote gate (.github/workflows/format-vote-gate.yml): +# any change to the proto definitions or the spec docs requires a PMC vote. +# Scoped to *.proto (not e.g. protos/AGENTS.md) since only the IDL and spec are +# the format. A PMC member waives a trivial edit with the `format-waived` label. +format-change: + - changed-files: + - any-glob-to-any-file: + - "protos/**/*.proto" + - "docs/src/format/**" + # Lockfiles are intentionally not excluded: a pure dependency bump gets both # A-python and A-deps. Over-labeling beats dropping the area signal on PRs that # touch python code alongside a lockfile. diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml new file mode 100644 index 00000000000..5cd3d483786 --- /dev/null +++ b/.github/workflows/ci-scripts.yml @@ -0,0 +1,38 @@ +name: CI scripts + +# Tests for helper scripts under ci/ that aren't covered by the language test +# suites (e.g. the format-spec vote gate logic). + +on: + push: + branches: + - main + - release/** + pull_request: + branches: + - main + - release/** + paths: + - ci/format_vote_gate.py + - ci/test_format_vote_gate.py + - .github/workflows/format-vote-gate.yml + - .github/workflows/ci-scripts.yml + +permissions: + contents: read + +jobs: + format-vote-gate: + name: Format vote gate unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Install pytest + run: pip install pytest + - name: Run tests + run: pytest ci/test_format_vote_gate.py diff --git a/.github/workflows/format-vote-gate.yml b/.github/workflows/format-vote-gate.yml new file mode 100644 index 00000000000..e98840830ea --- /dev/null +++ b/.github/workflows/format-vote-gate.yml @@ -0,0 +1,61 @@ +name: Format spec vote gate + +# Structurally enforces the PMC vote required for Lance format-specification +# changes (see https://lance.org/community/voting/). The path labeler +# (.github/labeler-area.yml) applies the `format-change` label to PRs that touch +# the format spec (`protos/**/*.proto`, `docs/src/format/**`); this gate reads +# that label and blocks merging until the PR has 3 binding +1 votes from PMC +# members (PR approvals, excluding the author), has no outstanding veto (a PMC +# "Request changes" review), and the 1-week voting period has elapsed. +# +# The gate publishes its verdict as the `format-spec-vote` commit status on the +# PR head. To make it a merge blocker, an org admin must add `format-spec-vote` +# as a required status check in the branch protection rules for `main` (and any +# release branches). The status is posted on *every* PR — success immediately +# for non-format PRs — so a required check is never left pending forever. +# +# A PMC member may waive a trivial edit (typo, wording, formatting) by applying +# the `format-waived` label. +# +# Uses pull_request_target so the token can post statuses/comments on fork-based +# PRs. It never checks out or executes PR code: it reads the trusted base +# checkout (for the PMC roster and this script) and queries the API only. + +on: + pull_request_target: + types: [opened, reopened, synchronize, labeled, unlabeled] + pull_request_review: + types: [submitted, edited, dismissed] + schedule: + # Re-evaluate open format-change PRs so the voting-period clock is re-checked + # even when no PR event fires (e.g. the 1-week period elapses days after the + # third approval lands). + - cron: "0 */8 * * *" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +jobs: + gate: + name: Evaluate format spec vote + runs-on: ubuntu-latest + steps: + - name: Checkout base + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install PyGithub PyYAML + - name: Evaluate vote + run: python ci/format_vote_gate.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index a9d339e36ce..51c899f56a3 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -22,69 +22,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: fail_on_error: true - format-vote-reminder: - permissions: - pull-requests: write - name: Remind about format spec vote - runs-on: ubuntu-latest - # Comments on PRs that touch the Lance format specification (*.proto files - # and docs/src/format/**) to remind the author that substantive format - # changes require a PMC vote. Re-checks the full PR diff on every push, so a - # format change introduced by a later commit is still caught; a hidden - # marker keeps it to at most one comment. - steps: - - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - const { owner, repo } = context.repo; - const prNumber = context.payload.pull_request.number; - const MARKER = ''; - - // The Lance format specification is the proto definitions plus the - // spec docs. Changes to either require a PMC vote. - const isFormatFile = (path) => - path.endsWith('.proto') || path.startsWith('docs/src/format/'); - - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, repo, pull_number: prNumber, per_page: 100, - }); - const formatFiles = files.map((f) => f.filename).filter(isFormatFile); - if (formatFiles.length === 0) { - core.info('No format specification files changed; nothing to do.'); - return; - } - - // Best effort to comment only once: skip if our marker is present. - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: prNumber, per_page: 100, - }); - if (comments.some((c) => c.body && c.body.includes(MARKER))) { - core.info('Reminder already posted; skipping.'); - return; - } - - const body = [ - MARKER, - '> [!IMPORTANT]', - '> **This PR touches the Lance format specification.**', - '>', - '> Substantive changes to the format specification — the `.proto` definitions', - '> and the spec docs under `docs/src/format/` — require a PMC vote before merge.', - '> Minor edits such as typo fixes, wording, or formatting are excluded; use your', - '> judgment.', - '>', - '> If this is a meaningful format change:', - '> - Start a vote following the [Lance community voting process](https://lance.org/community/voting/).', - '> Format specification modifications need **3 binding +1 votes** (excluding the', - '> proposer), held on GitHub Discussions, with a minimum voting period of **1 week**.', - '> - Once the vote passes, **link the completed vote in this PR**. It should not be', - '> merged until the vote is linked.', - ].join('\n'); - - await github.rest.issues.createComment({ - owner, repo, issue_number: prNumber, body, - }); - core.info(`Posted format vote reminder (changed: ${formatFiles.join(', ')}).`); commitlint: permissions: pull-requests: write diff --git a/ci/format_vote_gate.py b/ci/format_vote_gate.py new file mode 100644 index 00000000000..90f41b8490c --- /dev/null +++ b/ci/format_vote_gate.py @@ -0,0 +1,289 @@ +"""Format-specification vote gate (see `.github/workflows/format-vote-gate.yml`). + +Structurally enforces the PMC vote required for Lance format-specification +changes (https://lance.org/community/voting/). The `format-change` label is +applied by the path labeler (`.github/labeler-area.yml`); this script reads it +and publishes the `format-spec-vote` commit status, which blocks merging until: + + * 3 PMC members have approved the PR (excluding the author), counted only on + the head commit so new pushes invalidate stale approvals; + * no PMC member has an outstanding "Request changes" review (a veto); and + * the 1-week voting period (from when `format-change` was first applied) has + elapsed. + +A PMC member can waive a trivial edit by applying the `format-waived` label. +Non-format PRs get a passing status immediately and are otherwise left alone. + +The vote-counting rules are pure functions (`tally_reviews`, `decide_verdict`) +unit tested in `test_format_vote_gate.py`; `main` wires them to the GitHub API. +""" + +import json +import os +from datetime import datetime, timedelta, timezone + +STATUS_CONTEXT = "format-spec-vote" +FORMAT_LABEL = "format-change" +WAIVED_LABEL = "format-waived" +COMMENT_MARKER = "" +REQUIRED_APPROVALS = 3 +PERIOD_DAYS = 7 +VOTING_URL = "https://lance.org/community/voting/" + +# Review states that express a stance; COMMENTED/PENDING are ignored. +_STANCE_STATES = ("APPROVED", "CHANGES_REQUESTED", "DISMISSED") + + +def tally_reviews(reviews, head_sha, author, is_pmc): + """Tally PMC votes from a PR's reviews. + + `reviews` is an ordered list of dicts with `login`, `state`, `commit_id`. + A member's stance is their most recent stance review. Approvals only count + on the head commit; earlier ones are stale. A "changes requested" review is + a veto regardless of commit. The PR author never counts. + """ + latest = {} + for review in reviews: + login = review["login"] + if not login or not is_pmc(login) or login == author: + continue + if review["state"] not in _STANCE_STATES: + continue + latest[login.lower()] = review + + approvals, stale_approvals, vetoes = [], [], [] + for review in latest.values(): + if review["state"] == "APPROVED": + target = approvals if review["commit_id"] == head_sha else stale_approvals + target.append(review["login"]) + elif review["state"] == "CHANGES_REQUESTED": + vetoes.append(review["login"]) + return approvals, stale_approvals, vetoes + + +def decide_verdict(veto_count, approval_count, period_elapsed, required): + """Return the blocking condition (if any), in priority order.""" + if veto_count > 0: + return "veto" + if approval_count < required: + return "insufficient" + if not period_elapsed: + return "waiting_period" + return "pass" + + +def _fmt_list(logins): + return ", ".join(f"@{login}" for login in logins) if logins else "none" + + +def _as_utc(dt): + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + + +def _build_comment(headline, approval_cell, vetoes, period_cell): + return "\n".join( + [ + COMMENT_MARKER, + "> [!IMPORTANT]", + "> ## Format specification vote", + "", + "This PR modifies the Lance format specification, so it requires " + f"**{REQUIRED_APPROVALS} binding +1 votes from PMC members** " + "(excluding the proposer) and a minimum " + f"**{PERIOD_DAYS}-day** voting period before it can merge. " + "Vote by approving this PR (+1) or requesting changes (−1, a veto). " + f"See the [voting process]({VOTING_URL}).", + "", + f"**Status: {headline}**", + "", + "| | |", + "|---|---|", + f"| Approvals (this commit) | {approval_cell} |", + f"| Vetoes | {_fmt_list(vetoes)} |", + f"| Voting period | {period_cell} |", + "", + "Updated automatically by the format-spec vote gate. A PMC member " + f"may apply the `{WAIVED_LABEL}` label to waive the vote for a trivial " + "edit (typo, wording, formatting).", + ] + ) + + +def _load_pmc(workspace): + import yaml + + roster_path = os.path.join(workspace, "docs", "src", "community", "pmc.yaml") + with open(roster_path) as handle: + roster = yaml.safe_load(handle) + return {member["handle"].lower() for member in roster["members"]} + + +class Gate: + def __init__(self, repo, pmc, run_url): + self.repo = repo + self.pmc = pmc + self.run_url = run_url + + def is_pmc(self, login): + return login is not None and login.lower() in self.pmc + + def set_status(self, sha, state, description): + self.repo.get_commit(sha).create_status( + state=state, + context=STATUS_CONTEXT, + description=description[:140], + target_url=self.run_url, + ) + + def upsert_comment(self, issue, body): + for comment in issue.get_comments(): + if COMMENT_MARKER in (comment.body or ""): + if comment.body != body: + comment.edit(body) + return + issue.create_comment(body) + + def label_facts(self, issue): + """When `format-change` was first applied, and whether a PMC waived.""" + first_added = None + waived_by_pmc = False + for event in issue.get_events(): + if event.event not in ("labeled", "unlabeled") or event.label is None: + continue + name = event.label.name + actor = event.actor.login if event.actor else None + if ( + name == FORMAT_LABEL + and event.event == "labeled" + and first_added is None + ): + first_added = _as_utc(event.created_at) + elif ( + name == WAIVED_LABEL and event.event == "labeled" and self.is_pmc(actor) + ): + waived_by_pmc = True + return first_added, waived_by_pmc + + def evaluate(self, number): + pr = self.repo.get_pull(number) + if pr.state != "open": + print(f"PR #{number} is {pr.state}; skipping.") + return + head_sha = pr.head.sha + labels = {label.name for label in pr.labels} + + # Non-format PRs get a passing status and are otherwise left alone. + if FORMAT_LABEL not in labels: + self.set_status( + head_sha, "success", "No format-spec change; vote not required." + ) + print(f"PR #{number}: not a format change.") + return + + issue = self.repo.get_issue(number) + first_added, waived = self.label_facts(issue) + + if WAIVED_LABEL in labels and waived: + self.set_status( + head_sha, "success", "Format-spec vote waived by a PMC member." + ) + print(f"PR #{number}: vote waived.") + return + + reviews = [ + { + "login": review.user.login if review.user else None, + "state": review.state, + "commit_id": review.commit_id, + } + for review in pr.get_reviews() + ] + approvals, stale, vetoes = tally_reviews( + reviews, head_sha, pr.user.login, self.is_pmc + ) + + now = datetime.now(timezone.utc) + vote_start = first_added or now + period_ends = vote_start + timedelta(days=PERIOD_DAYS) + period_elapsed = now >= period_ends + verdict = decide_verdict( + len(vetoes), len(approvals), period_elapsed, REQUIRED_APPROVALS + ) + + end_date = period_ends.date().isoformat() + if verdict == "veto": + state, summary = "failure", f"Vetoed by {len(vetoes)} PMC member(s)." + headline = f"❌ Blocked — vetoed by {_fmt_list(vetoes)}" + elif verdict == "insufficient": + state = "failure" + summary = ( + f"{len(approvals)}/{REQUIRED_APPROVALS} PMC approvals on this commit." + ) + headline = f"❌ Blocked — {len(approvals)} of {REQUIRED_APPROVALS} required approvals" + elif verdict == "waiting_period": + state, summary = "failure", f"Approved; voting period ends {end_date}." + headline = ( + f"⏳ Approvals met ({len(approvals)}/{REQUIRED_APPROVALS}); " + f"voting period ends {end_date}" + ) + else: + state = "success" + summary = f"Passed — {len(approvals)} PMC approvals, period elapsed." + headline = f"✅ Vote passed — {len(approvals)} PMC approvals, voting period elapsed" + + days_left = max(0, -(-(period_ends - now).days)) # ceil of day difference + period_cell = ( + f"elapsed (ended {end_date})" + if period_elapsed + else f"ends {end_date} ({days_left} day(s) left)" + ) + approval_cell = ( + f"{_fmt_list(approvals)} ({len(approvals)}/{REQUIRED_APPROVALS})" + ) + if stale: + approval_cell += f" — stale, re-approve needed: {_fmt_list(stale)}" + + self.set_status(head_sha, state, summary) + self.upsert_comment( + issue, _build_comment(headline, approval_cell, vetoes, period_cell) + ) + print(f"PR #{number}: {summary}") + + +def main(): + from github import Github + + workspace = os.environ["GITHUB_WORKSPACE"] + token = os.environ["GITHUB_TOKEN"] + repo_name = os.environ["GITHUB_REPOSITORY"] + event_name = os.environ["GITHUB_EVENT_NAME"] + run_url = ( + f"{os.environ['GITHUB_SERVER_URL']}/{repo_name}/actions/runs/" + f"{os.environ['GITHUB_RUN_ID']}" + ) + + repo = Github(token).get_repo(repo_name) + gate = Gate(repo, _load_pmc(workspace), run_url) + + if event_name == "schedule": + # The schedule trigger has no PR context, so sweep every open + # format-change PR to re-check the voting-period clock. + pulls = [ + pr + for pr in repo.get_pulls(state="open") + if any(label.name == FORMAT_LABEL for label in pr.labels) + ] + print(f"Scheduled sweep: {len(pulls)} open {FORMAT_LABEL} PR(s).") + for pr in pulls: + try: + gate.evaluate(pr.number) + except Exception as err: # noqa: BLE001 - keep sweeping other PRs + print(f"PR #{pr.number}: {err}") + else: + with open(os.environ["GITHUB_EVENT_PATH"]) as handle: + event = json.load(handle) + gate.evaluate(event["pull_request"]["number"]) + + +if __name__ == "__main__": + main() diff --git a/ci/test_format_vote_gate.py b/ci/test_format_vote_gate.py new file mode 100644 index 00000000000..4b1b96ece53 --- /dev/null +++ b/ci/test_format_vote_gate.py @@ -0,0 +1,87 @@ +"""Unit tests for the format-spec vote gate logic. + +Run with: pytest ci/test_format_vote_gate.py +""" + +import pytest + +from format_vote_gate import decide_verdict, tally_reviews + +HEAD = "sha_head" +PMC = {"alice", "bob", "carol", "dave"} + + +def is_pmc(login): + return login is not None and login.lower() in PMC + + +def review(login, state, commit_id=HEAD): + return {"login": login, "state": state, "commit_id": commit_id} + + +def test_counts_distinct_pmc_approvals_on_head_commit(): + approvals, stale, vetoes = tally_reviews( + [ + review("alice", "APPROVED"), + review("bob", "APPROVED"), + review("carol", "APPROVED"), + ], + HEAD, + "author", + is_pmc, + ) + assert sorted(approvals) == ["alice", "bob", "carol"] + assert stale == [] + assert vetoes == [] + + +def test_only_latest_review_per_member_counts(): + # Alice approved, then later requested changes -> she is a veto, not approval. + approvals, _, vetoes = tally_reviews( + [review("alice", "APPROVED"), review("alice", "CHANGES_REQUESTED")], + HEAD, + "author", + is_pmc, + ) + assert approvals == [] + assert vetoes == ["alice"] + + +def test_approvals_on_earlier_commit_are_stale(): + approvals, stale, _ = tally_reviews( + [review("alice", "APPROVED", "old_sha"), review("bob", "APPROVED")], + HEAD, + "author", + is_pmc, + ) + assert approvals == ["bob"] + assert stale == ["alice"] + + +def test_ignores_author_non_pmc_and_dismissed(): + approvals, _, vetoes = tally_reviews( + [ + review("author", "APPROVED"), # PR author, even if PMC, never counts + review("eve", "APPROVED"), # not on the PMC + review("dave", "DISMISSED"), # withdrawn + review("carol", "COMMENTED"), # a comment is not a vote + ], + HEAD, + "author", + is_pmc, + ) + assert approvals == [] + assert vetoes == [] + + +@pytest.mark.parametrize( + ("veto_count", "approval_count", "period_elapsed", "expected"), + [ + (1, 5, True, "veto"), # veto wins even with enough approvals + elapsed + (0, 2, True, "insufficient"), + (0, 3, False, "waiting_period"), + (0, 3, True, "pass"), + ], +) +def test_decide_verdict_priority(veto_count, approval_count, period_elapsed, expected): + assert decide_verdict(veto_count, approval_count, period_elapsed, 3) == expected diff --git a/docs/hooks/pmc_roster.py b/docs/hooks/pmc_roster.py new file mode 100644 index 00000000000..3a56daa2b5d --- /dev/null +++ b/docs/hooks/pmc_roster.py @@ -0,0 +1,46 @@ +"""MkDocs hook: render the PMC roster table from `pmc.yaml` at build time. + +`docs/src/community/pmc.yaml` is the source of truth for the PMC roster (it also +drives the format-spec vote gate). The roster page contains the placeholder +``; this hook expands it into a Markdown table when the +docs are built, so the table never has to be maintained by hand. + +Registered via `hooks:` in `mkdocs.yml`. +""" + +import pathlib + +import yaml + +PLACEHOLDER = "" + +COLUMNS = [ + ("Name", "name"), + ("GitHub Handle", "handle"), + ("Affiliation", "affiliation"), + ("Ecosystem Roles", "ecosystem_roles"), +] + + +def _render_table(members): + headers = [title for title, _ in COLUMNS] + rows = [[str(m.get(key, "") or "") for _, key in COLUMNS] for m in members] + widths = [ + max([len(headers[i])] + [len(row[i]) for row in rows]) + for i in range(len(COLUMNS)) + ] + + def row(cells): + return "| " + " | ".join(c.ljust(widths[i]) for i, c in enumerate(cells)) + " |" + + lines = [row(headers), "|" + "|".join("-" * (w + 2) for w in widths) + "|"] + lines.extend(row(r) for r in rows) + return "\n".join(lines) + + +def on_page_markdown(markdown, page, config, files): + if PLACEHOLDER not in markdown: + return markdown + roster_path = pathlib.Path(config["docs_dir"]) / "community" / "pmc.yaml" + roster = yaml.safe_load(roster_path.read_text()) + return markdown.replace(PLACEHOLDER, _render_table(roster["members"])) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8144a42e5f5..ff7db4c3ab2 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -66,6 +66,9 @@ plugins: - mkdocs_protobuf: proto_dir: ../protos +hooks: + - hooks/pmc_roster.py + extra: generator: false social: diff --git a/docs/src/community/pmc.md b/docs/src/community/pmc.md index 3d9daeaebff..5088e45d6ae 100644 --- a/docs/src/community/pmc.md +++ b/docs/src/community/pmc.md @@ -27,27 +27,9 @@ In addition to the [activities of maintainers](./maintainers.md#activities), PMC ## Roster -| Name | GitHub Handle | Affiliation | Ecosystem Roles | -|-----------------|-----------------|--------------|---------------------------------------------------------------------------------------------------------------| -| Yang Cen | BubbleCal | LanceDB | Milvus Contributor | -| Pablo Delgado | pablete | Netflix | | -| Hao Ding | Xuanwo | LanceDB | Apache OpenDAL PMC Chair, Apache Iceberg Committer, Apache Member and [more](https://xuanwo.io/about/) | -| Zhaowei Huang | SaintBacchus | Alibaba | Apache Doris Committer | -| Will Jones | wjones127 | LanceDB | Apache Arrow PMC Member, Apache DataFusion PMC Member, Delta Lake Maintainer | -| Matt Kafonek | kafonek | Runway AI | | -| Denny Lee | dennyglee | Databricks | Unity Catalog Maintainer, Delta Lake Maintainer, Apache Spark Contributor, MLflow Contributor | -| Rob Meng | chebbyChefNEQ | Jump Trading | | -| Dao Mi | dowjones226 | Netflix | | -| Weston Pace | westonpace | LanceDB | Apache Arrow PMC Member, Substrait SMC Member | -| Calvin Qi | calvinqi | Harvey.ai | | -| Prashanth Rao | prrao87 | LanceDB | | -| Ethan Rosenthal | EthanRosenthal | Runway AI | | | -| Tim Saucer | timsaucer | Rerun.io | Apache DataFusion PMC Member | -| Chang She | changhiskhan | LanceDB | Pandas Co-Author | -| Jasmine Wang | onigiriisabunny | LanceDB | Alluxio PMC Community Manager | -| Lei Xu | eddyxu | LanceDB | Apache Hadoop PMC Member | -| Vino Yang | yanghua | Bytedance | Apache Hudi PMC Member, Apache Kyuubi PMC Member, Apache Kylin Committer, Apache Incubation Program Committer | -| Jack Ye | jackye1995 | LanceDB | Apache Iceberg PMC Member, Apache Polaris (incubating) PPMC Member, Apache Incubation Program Committer | + + ## Becoming a PMC Member diff --git a/docs/src/community/pmc.yaml b/docs/src/community/pmc.yaml new file mode 100644 index 00000000000..34819652c00 --- /dev/null +++ b/docs/src/community/pmc.yaml @@ -0,0 +1,90 @@ +# Source of truth for the Project Management Committee (PMC) roster. +# +# This file drives two things, so keep it accurate: +# 1. The roster table in `pmc.md`, rendered at docs build time by the +# `docs/hooks/pmc_roster.py` MkDocs hook. +# 2. The format-specification vote gate, which only counts PR approvals from +# the `handle`s listed here (see `.github/workflows/format-vote-gate.yml`). +# +# Adding or removing a member is itself a PMC vote (roster change). After the +# vote passes, edit this file; the docs table updates automatically. +# +# `handle` must match the member's GitHub login exactly (case-insensitive when +# matched). `ecosystem_roles` is free-form markdown and may be empty. +members: + - name: Yang Cen + handle: BubbleCal + affiliation: LanceDB + ecosystem_roles: Milvus Contributor + - name: Pablo Delgado + handle: pablete + affiliation: Netflix + ecosystem_roles: "" + - name: Hao Ding + handle: Xuanwo + affiliation: LanceDB + ecosystem_roles: Apache OpenDAL PMC Chair, Apache Iceberg Committer, Apache Member and [more](https://xuanwo.io/about/) + - name: Zhaowei Huang + handle: SaintBacchus + affiliation: Alibaba + ecosystem_roles: Apache Doris Committer + - name: Will Jones + handle: wjones127 + affiliation: LanceDB + ecosystem_roles: Apache Arrow PMC Member, Apache DataFusion PMC Member, Delta Lake Maintainer + - name: Matt Kafonek + handle: kafonek + affiliation: Runway AI + ecosystem_roles: "" + - name: Denny Lee + handle: dennyglee + affiliation: Databricks + ecosystem_roles: Unity Catalog Maintainer, Delta Lake Maintainer, Apache Spark Contributor, MLflow Contributor + - name: Rob Meng + handle: chebbyChefNEQ + affiliation: Jump Trading + ecosystem_roles: "" + - name: Dao Mi + handle: dowjones226 + affiliation: Netflix + ecosystem_roles: "" + - name: Weston Pace + handle: westonpace + affiliation: LanceDB + ecosystem_roles: Apache Arrow PMC Member, Substrait SMC Member + - name: Calvin Qi + handle: calvinqi + affiliation: Harvey.ai + ecosystem_roles: "" + - name: Prashanth Rao + handle: prrao87 + affiliation: LanceDB + ecosystem_roles: "" + - name: Ethan Rosenthal + handle: EthanRosenthal + affiliation: Runway AI + ecosystem_roles: "" + - name: Tim Saucer + handle: timsaucer + affiliation: Rerun.io + ecosystem_roles: Apache DataFusion PMC Member + - name: Chang She + handle: changhiskhan + affiliation: LanceDB + ecosystem_roles: Pandas Co-Author + - name: Jasmine Wang + handle: onigiriisabunny + affiliation: LanceDB + ecosystem_roles: Alluxio PMC Community Manager + - name: Lei Xu + handle: eddyxu + affiliation: LanceDB + ecosystem_roles: Apache Hadoop PMC Member + - name: Vino Yang + handle: yanghua + affiliation: Bytedance + ecosystem_roles: Apache Hudi PMC Member, Apache Kyuubi PMC Member, Apache Kylin Committer, Apache Incubation Program Committer + - name: Jack Ye + handle: jackye1995 + affiliation: LanceDB + ecosystem_roles: Apache Iceberg PMC Member, Apache Polaris (incubating) PPMC Member, Apache Incubation Program Committer diff --git a/docs/src/community/voting.md b/docs/src/community/voting.md index 8c5ac341e67..875a3029270 100644 --- a/docs/src/community/voting.md +++ b/docs/src/community/voting.md @@ -47,7 +47,37 @@ A **-1** binding vote is considered a veto for all decision types. Vetoes: | Release a new stable major version of the core project | 3 | PMC | GitHub Discussions | 3 days | | Release a new stable minor version of the core project | 3 | PMC | GitHub Discussions | 3 days | | Release a new stable patch version of the core project | 3 | PMC | GitHub Discussions | N/A | -| Lance Format Specification modifications | 3 (excluding proposer) | PMC | GitHub Discussions (with a GitHub PR) | 1 week | +| Lance Format Specification modifications | 3 (excluding proposer) | PMC | GitHub PR (see [below](#lance-format-specification-vote-gate)) | 1 week | | Code modifications in the core project (except changes to format specifications) | 1 (excluding proposer) | Maintainers with write access | GitHub PR | N/A | | Release a new stable version of subprojects | 1 | PMC | GitHub Discussions | N/A | | Code modifications in subprojects | 1 (excluding proposer) | Contributors with write access | GitHub PR | N/A | + +## Lance Format Specification Vote Gate + +Votes on Lance format-specification changes are cast and counted directly on the +pull request, and the requirement is enforced structurally in CI rather than by +convention. + +A PR is considered a format-specification change when it modifies the protobuf +definitions (`protos/**/*.proto`) or the spec documentation (`docs/src/format/**`); +such PRs are labelled `format-change` automatically. The +[format spec vote gate](https://github.com/lance-format/lance/blob/main/.github/workflows/format-vote-gate.yml) +blocks merging a `format-change` PR until all of the following hold: + +- **Three binding +1 votes.** Three PMC members have approved the PR, excluding + the proposer. Cast +1 by approving the PR. Only approvals on the latest commit + count — pushing new commits invalidates earlier approvals, since the proposal + has changed. +- **No veto.** No PMC member has an outstanding "Request changes" review. A `-1` + binding vote (cast by requesting changes) is a veto and blocks the merge until + withdrawn. +- **Minimum voting period.** At least one week has elapsed since the PR was + flagged as a format change. + +The gate is the `format-spec-vote` required status check on protected branches. +The PMC roster used to count votes is read from +[`docs/src/community/pmc.yaml`](./pmc.md). + +For a trivial edit that does not change the format — a typo, wording, or +formatting fix — a PMC member may apply the `format-waived` label to waive the +vote.