From 4b3adf10b699e97605e9798409f91745422b5fb3 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Mon, 22 Jun 2026 10:52:29 -0700 Subject: [PATCH 1/2] ci: enforce PMC vote on format-spec changes via CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format-specification changes (the `.proto` definitions and `docs/src/format/**`) require 3 binding +1 votes from PMC members, but nothing structurally enforced it — the requirement was only a reminder comment, caught socially if at all. This adds a CI gate that blocks merging a format-spec change until it has the required votes. A touched PR is labelled `format-change` and the gate publishes a `format-spec-vote` commit status that stays red until: - 3 PMC members have approved the PR (excluding the author), counted only on the latest 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 the label was first applied) has elapsed. The gate posts a live tally comment and runs on PR events, review events, and an 8-hourly cron (so the voting-period clock is re-checked even when no event fires). It uses pull_request_target to label/status fork PRs but never checks out or runs PR code. A PMC member can waive a trivial edit by removing the `format-change` label. The PMC roster moves to `docs/src/community/pmc.yaml` as the source of truth; `ci/sync_pmc_docs.py` regenerates the table in pmc.md (checked in docs-check), and the gate reads the roster from the base checkout so a PR cannot enlarge the electorate. The old reminder-only job is removed. To activate, add `format-spec-vote` as a required status check on protected branches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-scripts.yml | 36 +++ .github/workflows/docs-check.yml | 5 + .github/workflows/format-vote-gate.yml | 64 +++++ .github/workflows/pr-title.yml | 63 ----- ci/format_vote_gate.js | 311 +++++++++++++++++++++++++ ci/format_vote_gate.test.js | 92 ++++++++ ci/sync_pmc_docs.py | 100 ++++++++ docs/src/community/pmc.md | 6 +- docs/src/community/pmc.yaml | 90 +++++++ docs/src/community/voting.md | 31 ++- 10 files changed, 733 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/ci-scripts.yml create mode 100644 .github/workflows/format-vote-gate.yml create mode 100644 ci/format_vote_gate.js create mode 100644 ci/format_vote_gate.test.js create mode 100644 ci/sync_pmc_docs.py create mode 100644 docs/src/community/pmc.yaml diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml new file mode 100644 index 00000000000..ab7bb62cc40 --- /dev/null +++ b/.github/workflows/ci-scripts.yml @@ -0,0 +1,36 @@ +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.js + - ci/format_vote_gate.test.js + - .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 Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + - name: Run tests + run: node --test ci/format_vote_gate.test.js diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 2eda3de66b4..15576e42f22 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -11,6 +11,7 @@ on: - release/** paths: - docs/** + - ci/sync_pmc_docs.py - .github/workflows/docs-check.yml permissions: @@ -41,3 +42,7 @@ jobs: working-directory: docs run: | uv run mkdocs-linkcheck src + - name: Check PMC roster table is in sync with pmc.yaml + working-directory: docs + run: | + uv run python ../ci/sync_pmc_docs.py --check diff --git a/.github/workflows/format-vote-gate.yml b/.github/workflows/format-vote-gate.yml new file mode 100644 index 00000000000..ec32568d3bd --- /dev/null +++ b/.github/workflows/format-vote-gate.yml @@ -0,0 +1,64 @@ +name: Format spec vote gate + +# Structurally enforces the PMC vote required for Lance format-specification +# changes (see https://lance.org/community/voting/). A PR that touches the +# format spec (`protos/**/*.proto`, `docs/src/format/**`) is labelled +# `format-change` and cannot merge until it 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 exempt a trivial edit (typo, wording, formatting) by removing +# the `format-change` label; the gate respects that and will not re-add it. +# +# Uses pull_request_target so the token can label PRs and post statuses on +# fork-based PRs. It never checks out or executes PR code: it reads the trusted +# base checkout (for the PMC roster) 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 Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + - name: Install js-yaml + run: npm install --no-save js-yaml@4 + - name: Evaluate vote + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const path = require('path'); + // Loaded from the trusted base checkout, never from PR code. + const gate = require(path.join(process.env.GITHUB_WORKSPACE, 'ci', 'format_vote_gate.js')); + await gate.run({ github, context, core }); 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.js b/ci/format_vote_gate.js new file mode 100644 index 00000000000..bb5208fe8ca --- /dev/null +++ b/ci/format_vote_gate.js @@ -0,0 +1,311 @@ +// Logic for the format-specification vote gate +// (`.github/workflows/format-vote-gate.yml`). Extracted into a module so the +// vote-counting rules can be unit tested (see `format_vote_gate.test.js`); the +// workflow loads it from the trusted base checkout and calls `run`. + +const fs = require("fs"); +const path = require("path"); + +const STATUS_CONTEXT = "format-spec-vote"; +const LABEL = "format-change"; +const COMMENT_MARKER = ""; +const REQUIRED_APPROVALS = 3; +const PERIOD_DAYS = 7; +const PERIOD_MS = PERIOD_DAYS * 24 * 60 * 60 * 1000; +const VOTING_URL = "https://lance.org/community/voting/"; +const DAY_MS = 24 * 60 * 60 * 1000; + +// The Lance format specification is the proto definitions and the spec docs. +// Changes to either require a PMC vote. +function isFormatFile(p) { + return ( + (p.endsWith(".proto") && p.startsWith("protos/")) || + p.startsWith("docs/src/format/") + ); +} + +// Each PMC member's current stance is their most recent non-comment review. +// Approvals are only counted on the head commit (stale approvals from earlier +// commits do not count toward the bar); a "changes requested" review is a veto +// and blocks regardless of commit until withdrawn. The PR author never counts, +// even if they are on the PMC. +function tallyReviews(reviews, headSha, author, isPmc) { + const latest = new Map(); + for (const r of reviews) { + const login = r.user && r.user.login; + if (!login || !isPmc(login) || login === author) continue; + if (!["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(r.state)) { + continue; + } + latest.set(login.toLowerCase(), { + login, + state: r.state, + commitId: r.commit_id, + }); + } + const approvals = []; + const staleApprovals = []; + const vetoes = []; + for (const v of latest.values()) { + if (v.state === "APPROVED") { + (v.commitId === headSha ? approvals : staleApprovals).push(v.login); + } else if (v.state === "CHANGES_REQUESTED") { + vetoes.push(v.login); + } + } + return { approvals, staleApprovals, vetoes }; +} + +// Pure decision: which blocking condition (if any) applies, in priority order. +function decideVerdict({ vetoCount, approvalCount, periodElapsed, required }) { + if (vetoCount > 0) return "veto"; + if (approvalCount < required) return "insufficient"; + if (!periodElapsed) return "waiting_period"; + return "pass"; +} + +const fmtList = (xs) => (xs.length ? xs.map((u) => `@${u}`).join(", ") : "none"); + +function loadPmc(workspace) { + const yaml = require(path.join(workspace, "node_modules", "js-yaml")); + const rosterPath = path.join(workspace, "docs", "src", "community", "pmc.yaml"); + const roster = yaml.load(fs.readFileSync(rosterPath, "utf8")); + return new Set(roster.members.map((m) => m.handle.toLowerCase())); +} + +async function run({ github, context, core }) { + const { owner, repo } = context.repo; + const workspace = process.env.GITHUB_WORKSPACE; + const pmc = loadPmc(workspace); + const isPmc = (login) => login != null && pmc.has(login.toLowerCase()); + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + + async function setStatus(sha, state, description) { + await github.rest.repos.createCommitStatus({ + owner, + repo, + sha, + state, + context: STATUS_CONTEXT, + description: description.slice(0, 140), + target_url: runUrl, + }); + } + + async function upsertComment(prNumber, body) { + const full = `${COMMENT_MARKER}\n${body}`; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(COMMENT_MARKER)); + if (existing) { + if (existing.body !== full) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: full, + }); + } + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: full, + }); + } + } + + // Returns { firstAddedAt, removedByPmc } for LABEL on this PR, from the + // timeline: when the label was first applied (the vote-period anchor) and + // whether a PMC member deliberately removed it (a trivial-edit waiver). + async function labelHistory(prNumber) { + const events = await github.paginate( + github.rest.issues.listEventsForTimeline, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + let firstAddedAt = null; + let removedByPmc = false; + for (const e of events) { + if (!e.label || e.label.name !== LABEL) continue; + if (e.event === "labeled" && !firstAddedAt) { + firstAddedAt = e.created_at; + } else if (e.event === "unlabeled" && isPmc(e.actor && e.actor.login)) { + removedByPmc = true; + } + } + return { firstAddedAt, removedByPmc }; + } + + async function evaluate(prNumber) { + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + if (pr.state !== "open") { + core.info(`PR #${prNumber} is ${pr.state}; skipping.`); + return; + } + const headSha = pr.head.sha; + const author = pr.user.login; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + const touchesFormat = files.map((f) => f.filename).some(isFormatFile); + + let hasLabel = pr.labels.some((l) => l.name === LABEL); + const { firstAddedAt, removedByPmc } = await labelHistory(prNumber); + + // Auto-apply the label, unless a PMC member deliberately removed it. + if (touchesFormat && !hasLabel && !removedByPmc) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [LABEL], + }); + hasLabel = true; + } + + if (!hasLabel) { + await setStatus( + headSha, + "success", + removedByPmc + ? "Format-spec vote waived by a PMC member." + : "No format-spec change; vote not required.", + ); + if (removedByPmc) { + await upsertComment( + prNumber, + `> [!NOTE]\n> A PMC member removed the \`${LABEL}\` label, ` + + `waiving the format-spec vote for this PR (trivial edit).`, + ); + } + core.info(`PR #${prNumber}: no vote required.`); + return; + } + + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + const { approvals, staleApprovals, vetoes } = tallyReviews( + reviews, + headSha, + author, + isPmc, + ); + + const now = new Date(); + const voteStart = firstAddedAt ? new Date(firstAddedAt) : now; + const periodEnds = new Date(voteStart.getTime() + PERIOD_MS); + const periodElapsed = now >= periodEnds; + const verdict = decideVerdict({ + vetoCount: vetoes.length, + approvalCount: approvals.length, + periodElapsed, + required: REQUIRED_APPROVALS, + }); + + const endDate = periodEnds.toISOString().slice(0, 10); + let state, summary, headline; + if (verdict === "veto") { + state = "failure"; + summary = `Vetoed by ${vetoes.length} PMC member(s).`; + headline = `❌ Blocked — vetoed by ${fmtList(vetoes)}`; + } else if (verdict === "insufficient") { + state = "failure"; + summary = `${approvals.length}/${REQUIRED_APPROVALS} PMC approvals on this commit.`; + headline = `❌ Blocked — ${approvals.length} of ${REQUIRED_APPROVALS} required approvals`; + } else if (verdict === "waiting_period") { + state = "failure"; + summary = `Approved; voting period ends ${endDate}.`; + headline = + `⏳ Approvals met (${approvals.length}/${REQUIRED_APPROVALS}); ` + + `voting period ends ${endDate}`; + } else { + state = "success"; + summary = `Passed — ${approvals.length} PMC approvals, period elapsed.`; + headline = `✅ Vote passed — ${approvals.length} PMC approvals, voting period elapsed`; + } + + const daysLeft = Math.max(0, Math.ceil((periodEnds.getTime() - now.getTime()) / DAY_MS)); + const periodCell = periodElapsed + ? `elapsed (ended ${endDate})` + : `ends ${endDate} (${daysLeft} day(s) left)`; + const approvalCell = + `${fmtList(approvals)} (${approvals.length}/${REQUIRED_APPROVALS})` + + (staleApprovals.length + ? ` — stale, re-approve needed: ${fmtList(staleApprovals)}` + : ""); + + const body = [ + "> [!IMPORTANT]", + "> ## Format specification vote", + "", + "This PR modifies the Lance format specification, so it requires " + + `**${REQUIRED_APPROVALS} binding +1 votes from PMC members** ` + + "(excluding the proposer) and a minimum " + + `**${PERIOD_DAYS}-day** voting period before it can merge. ` + + "Vote by approving this PR (+1) or requesting changes (−1, a veto). " + + `See the [voting process](${VOTING_URL}).`, + "", + `**Status: ${headline}**`, + "", + "| | |", + "|---|---|", + `| Approvals (this commit) | ${approvalCell} |`, + `| Vetoes | ${fmtList(vetoes)} |`, + `| Voting period | ${periodCell} |`, + "", + "Updated automatically by the format-spec vote gate. A PMC member " + + `may remove the \`${LABEL}\` label to waive the vote for a trivial edit ` + + "(typo, wording, formatting).", + ].join("\n"); + + await setStatus(headSha, state, summary); + await upsertComment(prNumber, body); + core.info(`PR #${prNumber}: ${summary}`); + } + + if (context.eventName === "schedule") { + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + labels: LABEL, + per_page: 100, + }); + const pulls = issues.filter((i) => i.pull_request); + core.info(`Scheduled sweep: ${pulls.length} open ${LABEL} PR(s).`); + for (const p of pulls) { + try { + await evaluate(p.number); + } catch (err) { + core.warning(`PR #${p.number}: ${err.message}`); + } + } + } else { + await evaluate(context.payload.pull_request.number); + } +} + +module.exports = { + run, + isFormatFile, + tallyReviews, + decideVerdict, + constants: { STATUS_CONTEXT, LABEL, REQUIRED_APPROVALS, PERIOD_DAYS }, +}; diff --git a/ci/format_vote_gate.test.js b/ci/format_vote_gate.test.js new file mode 100644 index 00000000000..6a13e31eb1f --- /dev/null +++ b/ci/format_vote_gate.test.js @@ -0,0 +1,92 @@ +// Unit tests for the format-spec vote gate logic. +// Run with: node --test ci/format_vote_gate.test.js + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); + +const { isFormatFile, tallyReviews, decideVerdict } = require("./format_vote_gate.js"); + +test("isFormatFile matches protos and spec docs only", () => { + assert.ok(isFormatFile("protos/table.proto")); + assert.ok(isFormatFile("docs/src/format/file.md")); + // .proto outside protos/ (e.g. vendored or test fixtures) is not the spec. + assert.ok(!isFormatFile("rust/lance-encoding/foo.proto")); + assert.ok(!isFormatFile("docs/src/community/voting.md")); + assert.ok(!isFormatFile("README.md")); + assert.ok(!isFormatFile("protos/AGENTS.md")); +}); + +const HEAD = "sha_head"; +const isPmc = (login) => ["alice", "bob", "carol", "dave"].includes(login.toLowerCase()); +const review = (login, state, commitId = HEAD) => ({ user: { login }, state, commit_id: commitId }); + +test("counts distinct PMC approvals on the head commit", () => { + const { approvals, vetoes, staleApprovals } = tallyReviews( + [review("alice", "APPROVED"), review("bob", "APPROVED"), review("carol", "APPROVED")], + HEAD, + "author", + isPmc, + ); + assert.deepEqual(approvals.sort(), ["alice", "bob", "carol"]); + assert.equal(vetoes.length, 0); + assert.equal(staleApprovals.length, 0); +}); + +test("only a reviewer's latest review counts", () => { + // Alice approved, then later requested changes -> she is a veto, not approval. + const { approvals, vetoes } = tallyReviews( + [review("alice", "APPROVED"), review("alice", "CHANGES_REQUESTED")], + HEAD, + "author", + isPmc, + ); + assert.deepEqual(approvals, []); + assert.deepEqual(vetoes, ["alice"]); +}); + +test("approvals on an earlier commit are stale, not counted", () => { + const { approvals, staleApprovals } = tallyReviews( + [review("alice", "APPROVED", "old_sha"), review("bob", "APPROVED")], + HEAD, + "author", + isPmc, + ); + assert.deepEqual(approvals, ["bob"]); + assert.deepEqual(staleApprovals, ["alice"]); +}); + +test("ignores the author, non-PMC reviewers, and dismissed reviews", () => { + const { approvals, vetoes } = tallyReviews( + [ + 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", + isPmc, + ); + assert.deepEqual(approvals, []); + assert.deepEqual(vetoes, []); +}); + +test("decideVerdict applies blocking conditions in priority order", () => { + // Veto wins even with enough approvals and elapsed period. + assert.equal( + decideVerdict({ vetoCount: 1, approvalCount: 5, periodElapsed: true, required: 3 }), + "veto", + ); + assert.equal( + decideVerdict({ vetoCount: 0, approvalCount: 2, periodElapsed: true, required: 3 }), + "insufficient", + ); + assert.equal( + decideVerdict({ vetoCount: 0, approvalCount: 3, periodElapsed: false, required: 3 }), + "waiting_period", + ); + assert.equal( + decideVerdict({ vetoCount: 0, approvalCount: 3, periodElapsed: true, required: 3 }), + "pass", + ); +}); diff --git a/ci/sync_pmc_docs.py b/ci/sync_pmc_docs.py new file mode 100644 index 00000000000..86954acda85 --- /dev/null +++ b/ci/sync_pmc_docs.py @@ -0,0 +1,100 @@ +"""Regenerate the PMC roster table in docs from the machine-readable roster. + +`docs/src/community/pmc.yaml` is the source of truth for the PMC roster (it also +drives the format-specification vote gate). This script renders that data into +the roster table in `docs/src/community/pmc.md`, between the +`` and `` markers. + +Usage: + python ci/sync_pmc_docs.py # rewrite pmc.md in place + python ci/sync_pmc_docs.py --check # exit 1 if pmc.md is out of sync +""" + +import argparse +import pathlib +import sys + +import yaml + +COMMUNITY_DIR = pathlib.Path(__file__).resolve().parent.parent / "docs/src/community" +ROSTER_YAML = COMMUNITY_DIR / "pmc.yaml" +ROSTER_MD = COMMUNITY_DIR / "pmc.md" + +BEGIN_MARKER = "" +END_MARKER = "" + +COLUMNS = [ + ("Name", "name"), + ("GitHub Handle", "handle"), + ("Affiliation", "affiliation"), + ("Ecosystem Roles", "ecosystem_roles"), +] + + +def render_table(members): + rows = [ + [str(member.get(key, "") or "") for _, key in COLUMNS] for member in members + ] + headers = [title for title, _ in COLUMNS] + widths = [ + max(len(headers[i]), *(len(row[i]) for row in rows)) + if rows + else len(headers[i]) + for i in range(len(COLUMNS)) + ] + + def format_row(cells): + return ( + "| " + + " | ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells)) + + " |" + ) + + lines = [format_row(headers), "|" + "|".join("-" * (w + 2) for w in widths) + "|"] + lines.extend(format_row(row) for row in rows) + return "\n".join(lines) + + +def build_markdown(): + data = yaml.safe_load(ROSTER_YAML.read_text()) + members = data["members"] + table = render_table(members) + block = f"{BEGIN_MARKER}\n{table}\n{END_MARKER}" + + original = ROSTER_MD.read_text() + start = original.find(BEGIN_MARKER) + end = original.find(END_MARKER) + if start == -1 or end == -1: + raise SystemExit( + f"Could not find roster markers in {ROSTER_MD}. Expected " + f"{BEGIN_MARKER!r} and {END_MARKER!r}." + ) + return original[:start] + block + original[end + len(END_MARKER) :] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if pmc.md is out of sync instead of rewriting it.", + ) + args = parser.parse_args() + + updated = build_markdown() + if args.check: + if ROSTER_MD.read_text() != updated: + print( + f"{ROSTER_MD} is out of sync with {ROSTER_YAML}. " + "Run `python ci/sync_pmc_docs.py` and commit the result.", + file=sys.stderr, + ) + sys.exit(1) + print(f"{ROSTER_MD.name} is in sync with {ROSTER_YAML.name}.") + else: + ROSTER_MD.write_text(updated) + print(f"Wrote roster table to {ROSTER_MD}.") + + +if __name__ == "__main__": + main() diff --git a/docs/src/community/pmc.md b/docs/src/community/pmc.md index 3d9daeaebff..70cb0692def 100644 --- a/docs/src/community/pmc.md +++ b/docs/src/community/pmc.md @@ -27,6 +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 | @@ -41,13 +44,14 @@ In addition to the [activities of maintainers](./maintainers.md#activities), PMC | 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 | | | +| 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..673e5d7123d --- /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` (regenerated by `ci/sync_pmc_docs.py`). +# 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 and run `python ci/sync_pmc_docs.py` to update the +# docs table. +# +# `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..338da66790c 100644 --- a/docs/src/community/voting.md +++ b/docs/src/community/voting.md @@ -47,7 +47,36 @@ 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/**`). +The [format spec vote gate](https://github.com/lance-format/lance/blob/main/.github/workflows/format-vote-gate.yml) +labels such PRs `format-change` and blocks merging 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 remove the `format-change` label to waive the +vote. From 82c87750724cef075bac94cd6bfca73659a718a1 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Mon, 22 Jun 2026 11:25:11 -0700 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20address=20review=20=E2=80=94=20Python?= =?UTF-8?q?=20gate,=20path-labeler,=20build-time=20roster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to PR review feedback: - Rewrite the gate in Python (PyGithub) instead of JS, for readability — most maintainers are more familiar with Python. - Apply the `format-change` label via the existing path labeler (.github/labeler-area.yml) instead of the gate. The gate now reads the label and leaves PRs without it alone, posting only a passing status (so the required check never blocks non-format PRs). Trivial edits are waived with a `format-waived` label applied by a PMC member. - Render the PMC roster table at docs build time via an MkDocs hook (docs/hooks/pmc_roster.py) instead of a committed table kept in sync by a script; pmc.yaml stays the source of truth. Drops ci/sync_pmc_docs.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/labeler-area.yml | 10 + .github/workflows/ci-scripts.yml | 14 +- .github/workflows/docs-check.yml | 5 - .github/workflows/format-vote-gate.yml | 37 ++- ci/format_vote_gate.js | 311 ------------------------- ci/format_vote_gate.py | 289 +++++++++++++++++++++++ ci/format_vote_gate.test.js | 92 -------- ci/sync_pmc_docs.py | 100 -------- ci/test_format_vote_gate.py | 87 +++++++ docs/hooks/pmc_roster.py | 46 ++++ docs/mkdocs.yml | 3 + docs/src/community/pmc.md | 28 +-- docs/src/community/pmc.yaml | 6 +- docs/src/community/voting.md | 9 +- 14 files changed, 471 insertions(+), 566 deletions(-) delete mode 100644 ci/format_vote_gate.js create mode 100644 ci/format_vote_gate.py delete mode 100644 ci/format_vote_gate.test.js delete mode 100644 ci/sync_pmc_docs.py create mode 100644 ci/test_format_vote_gate.py create mode 100644 docs/hooks/pmc_roster.py 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 index ab7bb62cc40..5cd3d483786 100644 --- a/.github/workflows/ci-scripts.yml +++ b/.github/workflows/ci-scripts.yml @@ -13,8 +13,8 @@ on: - main - release/** paths: - - ci/format_vote_gate.js - - ci/format_vote_gate.test.js + - ci/format_vote_gate.py + - ci/test_format_vote_gate.py - .github/workflows/format-vote-gate.yml - .github/workflows/ci-scripts.yml @@ -28,9 +28,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - node-version: "20" + python-version: "3.12" + - name: Install pytest + run: pip install pytest - name: Run tests - run: node --test ci/format_vote_gate.test.js + run: pytest ci/test_format_vote_gate.py diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 15576e42f22..2eda3de66b4 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -11,7 +11,6 @@ on: - release/** paths: - docs/** - - ci/sync_pmc_docs.py - .github/workflows/docs-check.yml permissions: @@ -42,7 +41,3 @@ jobs: working-directory: docs run: | uv run mkdocs-linkcheck src - - name: Check PMC roster table is in sync with pmc.yaml - working-directory: docs - run: | - uv run python ../ci/sync_pmc_docs.py --check diff --git a/.github/workflows/format-vote-gate.yml b/.github/workflows/format-vote-gate.yml index ec32568d3bd..e98840830ea 100644 --- a/.github/workflows/format-vote-gate.yml +++ b/.github/workflows/format-vote-gate.yml @@ -1,9 +1,10 @@ name: Format spec vote gate # Structurally enforces the PMC vote required for Lance format-specification -# changes (see https://lance.org/community/voting/). A PR that touches the -# format spec (`protos/**/*.proto`, `docs/src/format/**`) is labelled -# `format-change` and cannot merge until it has 3 binding +1 votes from PMC +# 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. # @@ -13,12 +14,12 @@ name: Format spec vote gate # 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 exempt a trivial edit (typo, wording, formatting) by removing -# the `format-change` label; the gate respects that and will not re-add it. +# 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 label PRs and post statuses on -# fork-based PRs. It never checks out or executes PR code: it reads the trusted -# base checkout (for the PMC roster) and queries the API only. +# 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: @@ -48,17 +49,13 @@ jobs: steps: - name: Checkout base uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - node-version: "20" - - name: Install js-yaml - run: npm install --no-save js-yaml@4 + python-version: "3.12" + - name: Install dependencies + run: pip install PyGithub PyYAML - name: Evaluate vote - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - const path = require('path'); - // Loaded from the trusted base checkout, never from PR code. - const gate = require(path.join(process.env.GITHUB_WORKSPACE, 'ci', 'format_vote_gate.js')); - await gate.run({ github, context, core }); + run: python ci/format_vote_gate.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/ci/format_vote_gate.js b/ci/format_vote_gate.js deleted file mode 100644 index bb5208fe8ca..00000000000 --- a/ci/format_vote_gate.js +++ /dev/null @@ -1,311 +0,0 @@ -// Logic for the format-specification vote gate -// (`.github/workflows/format-vote-gate.yml`). Extracted into a module so the -// vote-counting rules can be unit tested (see `format_vote_gate.test.js`); the -// workflow loads it from the trusted base checkout and calls `run`. - -const fs = require("fs"); -const path = require("path"); - -const STATUS_CONTEXT = "format-spec-vote"; -const LABEL = "format-change"; -const COMMENT_MARKER = ""; -const REQUIRED_APPROVALS = 3; -const PERIOD_DAYS = 7; -const PERIOD_MS = PERIOD_DAYS * 24 * 60 * 60 * 1000; -const VOTING_URL = "https://lance.org/community/voting/"; -const DAY_MS = 24 * 60 * 60 * 1000; - -// The Lance format specification is the proto definitions and the spec docs. -// Changes to either require a PMC vote. -function isFormatFile(p) { - return ( - (p.endsWith(".proto") && p.startsWith("protos/")) || - p.startsWith("docs/src/format/") - ); -} - -// Each PMC member's current stance is their most recent non-comment review. -// Approvals are only counted on the head commit (stale approvals from earlier -// commits do not count toward the bar); a "changes requested" review is a veto -// and blocks regardless of commit until withdrawn. The PR author never counts, -// even if they are on the PMC. -function tallyReviews(reviews, headSha, author, isPmc) { - const latest = new Map(); - for (const r of reviews) { - const login = r.user && r.user.login; - if (!login || !isPmc(login) || login === author) continue; - if (!["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(r.state)) { - continue; - } - latest.set(login.toLowerCase(), { - login, - state: r.state, - commitId: r.commit_id, - }); - } - const approvals = []; - const staleApprovals = []; - const vetoes = []; - for (const v of latest.values()) { - if (v.state === "APPROVED") { - (v.commitId === headSha ? approvals : staleApprovals).push(v.login); - } else if (v.state === "CHANGES_REQUESTED") { - vetoes.push(v.login); - } - } - return { approvals, staleApprovals, vetoes }; -} - -// Pure decision: which blocking condition (if any) applies, in priority order. -function decideVerdict({ vetoCount, approvalCount, periodElapsed, required }) { - if (vetoCount > 0) return "veto"; - if (approvalCount < required) return "insufficient"; - if (!periodElapsed) return "waiting_period"; - return "pass"; -} - -const fmtList = (xs) => (xs.length ? xs.map((u) => `@${u}`).join(", ") : "none"); - -function loadPmc(workspace) { - const yaml = require(path.join(workspace, "node_modules", "js-yaml")); - const rosterPath = path.join(workspace, "docs", "src", "community", "pmc.yaml"); - const roster = yaml.load(fs.readFileSync(rosterPath, "utf8")); - return new Set(roster.members.map((m) => m.handle.toLowerCase())); -} - -async function run({ github, context, core }) { - const { owner, repo } = context.repo; - const workspace = process.env.GITHUB_WORKSPACE; - const pmc = loadPmc(workspace); - const isPmc = (login) => login != null && pmc.has(login.toLowerCase()); - const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; - - async function setStatus(sha, state, description) { - await github.rest.repos.createCommitStatus({ - owner, - repo, - sha, - state, - context: STATUS_CONTEXT, - description: description.slice(0, 140), - target_url: runUrl, - }); - } - - async function upsertComment(prNumber, body) { - const full = `${COMMENT_MARKER}\n${body}`; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: prNumber, - per_page: 100, - }); - const existing = comments.find((c) => c.body && c.body.includes(COMMENT_MARKER)); - if (existing) { - if (existing.body !== full) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body: full, - }); - } - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: full, - }); - } - } - - // Returns { firstAddedAt, removedByPmc } for LABEL on this PR, from the - // timeline: when the label was first applied (the vote-period anchor) and - // whether a PMC member deliberately removed it (a trivial-edit waiver). - async function labelHistory(prNumber) { - const events = await github.paginate( - github.rest.issues.listEventsForTimeline, - { owner, repo, issue_number: prNumber, per_page: 100 }, - ); - let firstAddedAt = null; - let removedByPmc = false; - for (const e of events) { - if (!e.label || e.label.name !== LABEL) continue; - if (e.event === "labeled" && !firstAddedAt) { - firstAddedAt = e.created_at; - } else if (e.event === "unlabeled" && isPmc(e.actor && e.actor.login)) { - removedByPmc = true; - } - } - return { firstAddedAt, removedByPmc }; - } - - async function evaluate(prNumber) { - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }); - if (pr.state !== "open") { - core.info(`PR #${prNumber} is ${pr.state}; skipping.`); - return; - } - const headSha = pr.head.sha; - const author = pr.user.login; - - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, - repo, - pull_number: prNumber, - per_page: 100, - }); - const touchesFormat = files.map((f) => f.filename).some(isFormatFile); - - let hasLabel = pr.labels.some((l) => l.name === LABEL); - const { firstAddedAt, removedByPmc } = await labelHistory(prNumber); - - // Auto-apply the label, unless a PMC member deliberately removed it. - if (touchesFormat && !hasLabel && !removedByPmc) { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: [LABEL], - }); - hasLabel = true; - } - - if (!hasLabel) { - await setStatus( - headSha, - "success", - removedByPmc - ? "Format-spec vote waived by a PMC member." - : "No format-spec change; vote not required.", - ); - if (removedByPmc) { - await upsertComment( - prNumber, - `> [!NOTE]\n> A PMC member removed the \`${LABEL}\` label, ` + - `waiving the format-spec vote for this PR (trivial edit).`, - ); - } - core.info(`PR #${prNumber}: no vote required.`); - return; - } - - const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner, - repo, - pull_number: prNumber, - per_page: 100, - }); - const { approvals, staleApprovals, vetoes } = tallyReviews( - reviews, - headSha, - author, - isPmc, - ); - - const now = new Date(); - const voteStart = firstAddedAt ? new Date(firstAddedAt) : now; - const periodEnds = new Date(voteStart.getTime() + PERIOD_MS); - const periodElapsed = now >= periodEnds; - const verdict = decideVerdict({ - vetoCount: vetoes.length, - approvalCount: approvals.length, - periodElapsed, - required: REQUIRED_APPROVALS, - }); - - const endDate = periodEnds.toISOString().slice(0, 10); - let state, summary, headline; - if (verdict === "veto") { - state = "failure"; - summary = `Vetoed by ${vetoes.length} PMC member(s).`; - headline = `❌ Blocked — vetoed by ${fmtList(vetoes)}`; - } else if (verdict === "insufficient") { - state = "failure"; - summary = `${approvals.length}/${REQUIRED_APPROVALS} PMC approvals on this commit.`; - headline = `❌ Blocked — ${approvals.length} of ${REQUIRED_APPROVALS} required approvals`; - } else if (verdict === "waiting_period") { - state = "failure"; - summary = `Approved; voting period ends ${endDate}.`; - headline = - `⏳ Approvals met (${approvals.length}/${REQUIRED_APPROVALS}); ` + - `voting period ends ${endDate}`; - } else { - state = "success"; - summary = `Passed — ${approvals.length} PMC approvals, period elapsed.`; - headline = `✅ Vote passed — ${approvals.length} PMC approvals, voting period elapsed`; - } - - const daysLeft = Math.max(0, Math.ceil((periodEnds.getTime() - now.getTime()) / DAY_MS)); - const periodCell = periodElapsed - ? `elapsed (ended ${endDate})` - : `ends ${endDate} (${daysLeft} day(s) left)`; - const approvalCell = - `${fmtList(approvals)} (${approvals.length}/${REQUIRED_APPROVALS})` + - (staleApprovals.length - ? ` — stale, re-approve needed: ${fmtList(staleApprovals)}` - : ""); - - const body = [ - "> [!IMPORTANT]", - "> ## Format specification vote", - "", - "This PR modifies the Lance format specification, so it requires " + - `**${REQUIRED_APPROVALS} binding +1 votes from PMC members** ` + - "(excluding the proposer) and a minimum " + - `**${PERIOD_DAYS}-day** voting period before it can merge. ` + - "Vote by approving this PR (+1) or requesting changes (−1, a veto). " + - `See the [voting process](${VOTING_URL}).`, - "", - `**Status: ${headline}**`, - "", - "| | |", - "|---|---|", - `| Approvals (this commit) | ${approvalCell} |`, - `| Vetoes | ${fmtList(vetoes)} |`, - `| Voting period | ${periodCell} |`, - "", - "Updated automatically by the format-spec vote gate. A PMC member " + - `may remove the \`${LABEL}\` label to waive the vote for a trivial edit ` + - "(typo, wording, formatting).", - ].join("\n"); - - await setStatus(headSha, state, summary); - await upsertComment(prNumber, body); - core.info(`PR #${prNumber}: ${summary}`); - } - - if (context.eventName === "schedule") { - const issues = await github.paginate(github.rest.issues.listForRepo, { - owner, - repo, - state: "open", - labels: LABEL, - per_page: 100, - }); - const pulls = issues.filter((i) => i.pull_request); - core.info(`Scheduled sweep: ${pulls.length} open ${LABEL} PR(s).`); - for (const p of pulls) { - try { - await evaluate(p.number); - } catch (err) { - core.warning(`PR #${p.number}: ${err.message}`); - } - } - } else { - await evaluate(context.payload.pull_request.number); - } -} - -module.exports = { - run, - isFormatFile, - tallyReviews, - decideVerdict, - constants: { STATUS_CONTEXT, LABEL, REQUIRED_APPROVALS, PERIOD_DAYS }, -}; 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/format_vote_gate.test.js b/ci/format_vote_gate.test.js deleted file mode 100644 index 6a13e31eb1f..00000000000 --- a/ci/format_vote_gate.test.js +++ /dev/null @@ -1,92 +0,0 @@ -// Unit tests for the format-spec vote gate logic. -// Run with: node --test ci/format_vote_gate.test.js - -const { test } = require("node:test"); -const assert = require("node:assert/strict"); - -const { isFormatFile, tallyReviews, decideVerdict } = require("./format_vote_gate.js"); - -test("isFormatFile matches protos and spec docs only", () => { - assert.ok(isFormatFile("protos/table.proto")); - assert.ok(isFormatFile("docs/src/format/file.md")); - // .proto outside protos/ (e.g. vendored or test fixtures) is not the spec. - assert.ok(!isFormatFile("rust/lance-encoding/foo.proto")); - assert.ok(!isFormatFile("docs/src/community/voting.md")); - assert.ok(!isFormatFile("README.md")); - assert.ok(!isFormatFile("protos/AGENTS.md")); -}); - -const HEAD = "sha_head"; -const isPmc = (login) => ["alice", "bob", "carol", "dave"].includes(login.toLowerCase()); -const review = (login, state, commitId = HEAD) => ({ user: { login }, state, commit_id: commitId }); - -test("counts distinct PMC approvals on the head commit", () => { - const { approvals, vetoes, staleApprovals } = tallyReviews( - [review("alice", "APPROVED"), review("bob", "APPROVED"), review("carol", "APPROVED")], - HEAD, - "author", - isPmc, - ); - assert.deepEqual(approvals.sort(), ["alice", "bob", "carol"]); - assert.equal(vetoes.length, 0); - assert.equal(staleApprovals.length, 0); -}); - -test("only a reviewer's latest review counts", () => { - // Alice approved, then later requested changes -> she is a veto, not approval. - const { approvals, vetoes } = tallyReviews( - [review("alice", "APPROVED"), review("alice", "CHANGES_REQUESTED")], - HEAD, - "author", - isPmc, - ); - assert.deepEqual(approvals, []); - assert.deepEqual(vetoes, ["alice"]); -}); - -test("approvals on an earlier commit are stale, not counted", () => { - const { approvals, staleApprovals } = tallyReviews( - [review("alice", "APPROVED", "old_sha"), review("bob", "APPROVED")], - HEAD, - "author", - isPmc, - ); - assert.deepEqual(approvals, ["bob"]); - assert.deepEqual(staleApprovals, ["alice"]); -}); - -test("ignores the author, non-PMC reviewers, and dismissed reviews", () => { - const { approvals, vetoes } = tallyReviews( - [ - 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", - isPmc, - ); - assert.deepEqual(approvals, []); - assert.deepEqual(vetoes, []); -}); - -test("decideVerdict applies blocking conditions in priority order", () => { - // Veto wins even with enough approvals and elapsed period. - assert.equal( - decideVerdict({ vetoCount: 1, approvalCount: 5, periodElapsed: true, required: 3 }), - "veto", - ); - assert.equal( - decideVerdict({ vetoCount: 0, approvalCount: 2, periodElapsed: true, required: 3 }), - "insufficient", - ); - assert.equal( - decideVerdict({ vetoCount: 0, approvalCount: 3, periodElapsed: false, required: 3 }), - "waiting_period", - ); - assert.equal( - decideVerdict({ vetoCount: 0, approvalCount: 3, periodElapsed: true, required: 3 }), - "pass", - ); -}); diff --git a/ci/sync_pmc_docs.py b/ci/sync_pmc_docs.py deleted file mode 100644 index 86954acda85..00000000000 --- a/ci/sync_pmc_docs.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Regenerate the PMC roster table in docs from the machine-readable roster. - -`docs/src/community/pmc.yaml` is the source of truth for the PMC roster (it also -drives the format-specification vote gate). This script renders that data into -the roster table in `docs/src/community/pmc.md`, between the -`` and `` markers. - -Usage: - python ci/sync_pmc_docs.py # rewrite pmc.md in place - python ci/sync_pmc_docs.py --check # exit 1 if pmc.md is out of sync -""" - -import argparse -import pathlib -import sys - -import yaml - -COMMUNITY_DIR = pathlib.Path(__file__).resolve().parent.parent / "docs/src/community" -ROSTER_YAML = COMMUNITY_DIR / "pmc.yaml" -ROSTER_MD = COMMUNITY_DIR / "pmc.md" - -BEGIN_MARKER = "" -END_MARKER = "" - -COLUMNS = [ - ("Name", "name"), - ("GitHub Handle", "handle"), - ("Affiliation", "affiliation"), - ("Ecosystem Roles", "ecosystem_roles"), -] - - -def render_table(members): - rows = [ - [str(member.get(key, "") or "") for _, key in COLUMNS] for member in members - ] - headers = [title for title, _ in COLUMNS] - widths = [ - max(len(headers[i]), *(len(row[i]) for row in rows)) - if rows - else len(headers[i]) - for i in range(len(COLUMNS)) - ] - - def format_row(cells): - return ( - "| " - + " | ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells)) - + " |" - ) - - lines = [format_row(headers), "|" + "|".join("-" * (w + 2) for w in widths) + "|"] - lines.extend(format_row(row) for row in rows) - return "\n".join(lines) - - -def build_markdown(): - data = yaml.safe_load(ROSTER_YAML.read_text()) - members = data["members"] - table = render_table(members) - block = f"{BEGIN_MARKER}\n{table}\n{END_MARKER}" - - original = ROSTER_MD.read_text() - start = original.find(BEGIN_MARKER) - end = original.find(END_MARKER) - if start == -1 or end == -1: - raise SystemExit( - f"Could not find roster markers in {ROSTER_MD}. Expected " - f"{BEGIN_MARKER!r} and {END_MARKER!r}." - ) - return original[:start] + block + original[end + len(END_MARKER) :] - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--check", - action="store_true", - help="Exit non-zero if pmc.md is out of sync instead of rewriting it.", - ) - args = parser.parse_args() - - updated = build_markdown() - if args.check: - if ROSTER_MD.read_text() != updated: - print( - f"{ROSTER_MD} is out of sync with {ROSTER_YAML}. " - "Run `python ci/sync_pmc_docs.py` and commit the result.", - file=sys.stderr, - ) - sys.exit(1) - print(f"{ROSTER_MD.name} is in sync with {ROSTER_YAML.name}.") - else: - ROSTER_MD.write_text(updated) - print(f"Wrote roster table to {ROSTER_MD}.") - - -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 70cb0692def..5088e45d6ae 100644 --- a/docs/src/community/pmc.md +++ b/docs/src/community/pmc.md @@ -27,31 +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 index 673e5d7123d..34819652c00 100644 --- a/docs/src/community/pmc.yaml +++ b/docs/src/community/pmc.yaml @@ -1,13 +1,13 @@ # 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` (regenerated by `ci/sync_pmc_docs.py`). +# 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 and run `python ci/sync_pmc_docs.py` to update the -# docs table. +# 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. diff --git a/docs/src/community/voting.md b/docs/src/community/voting.md index 338da66790c..875a3029270 100644 --- a/docs/src/community/voting.md +++ b/docs/src/community/voting.md @@ -59,9 +59,10 @@ 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/**`). -The [format spec vote gate](https://github.com/lance-format/lance/blob/main/.github/workflows/format-vote-gate.yml) -labels such PRs `format-change` and blocks merging until all of the following hold: +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 @@ -78,5 +79,5 @@ 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 remove the `format-change` label to waive the +formatting fix — a PMC member may apply the `format-waived` label to waive the vote.