From 8e7c45c735f789cde67bfe4c5c62a346609b81c4 Mon Sep 17 00:00:00 2001 From: azim afroozeh <13484327+azimafroozeh@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:46:15 +0200 Subject: [PATCH 1/2] ci: refuse pull request titles without a Conventional Commits type --- .github/PULL_REQUEST_TEMPLATE.md | 2 + .github/branch-protection.json | 5 +- .github/workflows/pr-title.yml | 58 +++++++ AGENTS.md | 4 + CONTRIBUTING.md | 21 +++ docs/dev/branch-protection.md | 32 +++- docs/dev/ci.md | 34 +++- scripts/check-pr-title.py | 261 +++++++++++++++++++++++++++++++ 8 files changed, 402 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/pr-title.yml create mode 100755 scripts/check-pr-title.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index faf68f882..d6709bf6f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,8 @@ ## What & why diff --git a/.github/branch-protection.json b/.github/branch-protection.json index fed40b5e6..d4ba52c78 100644 --- a/.github/branch-protection.json +++ b/.github/branch-protection.json @@ -1,12 +1,13 @@ { - "_comment": "Branch protection policy for main. Applied by an administrator via scripts/apply-branch-protection.sh; CI never mutates it. See docs/dev/branch-protection.md for rationale. Four required contexts, each a name that does not change when CI changes: CI Gate is the ci.yml job `ci_gate`, which needs every ci.yml job that blocks main and runs scripts/ci_gate.py over their results (a skip is accepted only where the job's own `if:` was false; failure and cancellation are always red), so which jobs block main is the `needs:` list in ci.yml and the table in that script, changed in a reviewed pull request and never on this rule. Check Workflow Action Pins is required on its own, not only through the gate: it runs scripts/check-merge-group-triggers.py, which pins the gate's wiring and holds its `needs:` equal to the script's table, and a malformed gate that reported a skip could otherwise hide that validator's red. GQ Logic Tests and Fix Regression Gate are their own workflows and keep their own names. .github/CODEOWNERS is advisory, require_code_owner_reviews stays false and no approvals are required, so the four contexts are the gate. The format fence, the RustFS S3 suite and the Azurite suite run on pull requests as reporting contexts and again after merge. A red main branch is stop-the-line until fixed or reverted. Every merge into main goes through the merge queue with the squash method; the branch-protection endpoint this file drives has no field for the queue, so it is set on the rule page and checked there after every apply. Each required context's workflow also runs on merge_group (scripts/check-merge-group-triggers.py enforces it); Fix Regression Gate's merge-group run reports a pass without a check. No administrator bypass: enforce_admins is true.", + "_comment": "Branch protection policy for main. Applied by an administrator via scripts/apply-branch-protection.sh; CI never mutates it. See docs/dev/branch-protection.md for rationale. Five required contexts, each a name that does not change when CI changes: CI Gate is the ci.yml job `ci_gate`, which needs every ci.yml job that blocks main and runs scripts/ci_gate.py over their results (a skip is accepted only where the job's own `if:` was false; failure and cancellation are always red), so which jobs block main is the `needs:` list in ci.yml and the table in that script, changed in a reviewed pull request and never on this rule. Check Workflow Action Pins is required on its own, not only through the gate: it runs scripts/check-merge-group-triggers.py, which pins the gate's wiring and holds its `needs:` equal to the script's table, and a malformed gate that reported a skip could otherwise hide that validator's red. GQ Logic Tests, Fix Regression Gate and PR Title are their own workflows and keep their own names. .github/CODEOWNERS is advisory, require_code_owner_reviews stays false and no approvals are required, so the five contexts are the gate. The format fence, the RustFS S3 suite and the Azurite suite run on pull requests as reporting contexts and again after merge. A red main branch is stop-the-line until fixed or reverted. Every merge into main goes through the merge queue with the squash method; the branch-protection endpoint this file drives has no field for the queue, so it is set on the rule page and checked there after every apply. Each required context's workflow also runs on merge_group (scripts/check-merge-group-triggers.py enforces it); Fix Regression Gate's merge-group run reports a pass without a check, and PR Title's merge-group run checks the squash subject the queue is about to land. PR Title holds every title to type(scope)!: description (scripts/check-pr-title.py; CONTRIBUTING.md, Pull Requests), so the squash subject on main names the kind of change. No administrator bypass: enforce_admins is true.", "required_status_checks": { "strict": true, "contexts": [ "CI Gate", "Check Workflow Action Pins", "GQ Logic Tests", - "Fix Regression Gate" + "Fix Regression Gate", + "PR Title" ] }, "enforce_admins": true, diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 000000000..fbaaa2863 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,58 @@ +name: PR Title + +# Every merge into `main` is a squash whose subject is the pull request +# title, so the title is the line `git log` keeps. This is a policy check on +# the pull request, so it must run code the pull request cannot edit: +# `pull_request_target` takes this workflow and `scripts/check-pr-title.py` +# from the base branch, and the head is never fetched or checked out. The +# `edited` event re-runs the check when the title changes. On the merge +# queue the group commit's message already carries the squash subject the +# queue will land, so the merge-group run checks that subject with the same +# script; a title edit after queueing does not change the captured subject +# (see docs/dev/branch-protection.md, Merge queue). +on: + pull_request_target: + types: + - opened + - synchronize + - reopened + - edited + merge_group: + +concurrency: + # A merge-group run has no pull request number; its ref is unique per entry. + group: pr-title-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pr_title: + name: PR Title + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + # The base branch on a pull request; the queued commit on the merge group. + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Self-test the checker + run: python3 scripts/check-pr-title.py --self-test + + - name: Check the pull request title + if: github.event_name == 'pull_request_target' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: python3 scripts/check-pr-title.py --title "$PR_TITLE" + + - name: Check the subject the merge queue lands + if: github.event_name == 'merge_group' + env: + HEAD_COMMIT_MESSAGE: ${{ github.event.merge_group.head_commit.message }} + run: python3 scripts/check-pr-title.py --squash-subject "$HEAD_COMMIT_MESSAGE" + + - name: Refuse an event no step handles + if: github.event_name != 'pull_request_target' && github.event_name != 'merge_group' + run: | + echo "::error::PR Title ran on ${GITHUB_EVENT_NAME}, which no step handles" + exit 1 diff --git a/AGENTS.md b/AGENTS.md index ed9d3c44c..3e00de38e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,10 @@ Set `OMNIGRAPH_UPDATE_OPENAPI=1` only when the drift is intentional. - Add a permanent `changelog.d/..md` note for user-visible changes; follow [release-note authoring](docs/dev/documentation.md#release-notes). Keep private tickets and planning shorthand out of public history. +- Title a pull request `type(scope)!: description` and read the title back + after opening it; the `PR Title` check refuses every other shape, and the + squash subject on `main` is that title + ([CONTRIBUTING.md](CONTRIBUTING.md), Pull Requests). - Recheck exact flags, environment variables, routes, and constants in source before documenting them. - Keep this file a map. New deep content goes in its audience-owned guide. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0e3ae116..383b956e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,27 @@ CI runs both. ## Pull Requests +- **Title the PR `type(scope)!: description`** ([Conventional + Commits](https://www.conventionalcommits.org/en/v1.0.0/)). Every merge is a + squash whose subject is the title, so the title is the line `git log` keeps. + The `PR Title` check refuses anything else and re-runs when you edit the + title: + - `type` is one of `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `ci`, + `build`, `revert`, `rfc`, `release`; there is no `chore`, say what kind + of change it is. + - `(scope)` is optional: lowercase names, commas between several + (`refactor(blob,schema)!: …`). + - `!` marks a breaking change; the change also carries a + `changelog.d/.breaking.md` note. + - the description starts lowercase (a backtick or a digit is fine), has no + trailing period, and the whole title stays within 100 characters. + - an RFC PR is `rfc: `, not `docs(rfc):`; RFC numbers + stay out of titles (RFCs are named by date and slug). A revert is + `revert: `; retitle GitHub's `Revert "…"` default. + - examples: `feat(server): return exact merge publication receipts`, + `fix(merge): decide fast_forward by the target's state`, + `test(gq): cover inherited indexes after fast-forward`, + `docs: fix a broken link in the upgrade guide`. - **Link the backing `accepted` issue or accepted RFC** (`Closes #123`, or reference the RFC) — or mark the PR as trivial per the fast-lane. - Keep changes focused; one logical change per PR. diff --git a/docs/dev/branch-protection.md b/docs/dev/branch-protection.md index 8248f5370..ef918f1e1 100644 --- a/docs/dev/branch-protection.md +++ b/docs/dev/branch-protection.md @@ -6,13 +6,14 @@ protection. ## Current policy -`main` requires these four PR status contexts, names that do not change when +`main` requires these five PR status contexts, names that do not change when CI changes: - `CI Gate` - `Check Workflow Action Pins` - `GQ Logic Tests` - `Fix Regression Gate` +- `PR Title` Checks are strict; under the merge queue the entry is built on the current `main`, and GitHub does not require the author to update the pull request @@ -126,8 +127,8 @@ temporary `gh-readonly-queue/main/…` branch from the current `main` plus the entries ahead of it, fires the `merge_group` event there, and merges only when every required context has reported green on that branch. A required context that never reports on the merge-group branch holds the entry until the queue's -status-check timeout removes it, so `ci.yml`, `gq-logic-tests.yml`, and -`fix-regression-gate.yml` list `merge_group` under `on:`, the `Format +status-check timeout removes it, so `ci.yml`, `gq-logic-tests.yml`, +`fix-regression-gate.yml`, and `pr-title.yml` list `merge_group` under `on:`, the `Format (rustfmt)` and `Lint (clippy)` job conditions accept the event beside `pull_request`, and `scripts/check-merge-group-triggers.py`, a step of `Check Workflow Action Pins`, fails the build when a workflow owning a required @@ -160,6 +161,23 @@ stays open: a body or label edit after queueing re-runs the gate on the pull request only; whether a red result there removes the entry is not documented by GitHub, and the red context stays visible on the pull request either way. +`PR Title` (`pr-title.yml`) holds the pull request title to +`type(scope)!: description` ([CONTRIBUTING.md](../../CONTRIBUTING.md), Pull +Requests; `scripts/check-pr-title.py` is the check). Every merge is a squash +whose subject is the title, so the title is the one line `git log` keeps for +the change. On the pull request it runs on `pull_request_target`, so the +checker comes from `main`, and a title edit (`edited`) re-runs it. On the +merge-group run the group commit's message carries the squash subject the +queue will land (`merge_group.head_commit.message`, first line, with the +` (#N)` suffix GitHub appends), so the same checker runs on that subject. +A title edited after queueing does not change that captured subject: the +edit re-runs only the pull-request check, and whether a red result there +removes the entry is undocumented, as for the regression gate above. That +the landed commit's subject is the captured one is inferred from the +payload, not documented; one observed queue run after rollout confirms it. +This merge-group run executes the queued commit's copy of the checker, like +every merge-group run; the policy copy ran on the pull request. + Landing a pull request: click **Merge when ready** once the reporting contexts have reported; GitHub queues the pull request when its required contexts are green. A queued entry whose merge-group checks fail, or that hits @@ -183,7 +201,7 @@ the post-merge runs (`Test Workspace`, the fence, the shards, Azurite, the vocabulary audit, the edge release) cover the whole group at once, and a revert picks the entry's own squash commit out of that push. -Changing one of the four required context names still needs the rule page: +Changing one of the five required context names still needs the rule page: the old name never reports again, so loosen the live rule first, land the change (the JSON edit rides in the same pull request as the workflow edit; the validator refuses the pair split), then re-apply the JSON; other pull @@ -212,8 +230,8 @@ If the gate or the validator cannot execute at all (the checkout action or the runners are down), no `needs:` edit helps, and the settings-based route remains: an administrator unticks the context that cannot report on the rule page, lands the fix, and re-applies the JSON. Unticking `CI Gate` relaxes -every job inside it at once and leaves the other three contexts required; -hold unrelated merges while it is unticked, and verify all four names and +every job inside it at once and leaves the other four contexts required; +hold unrelated merges while it is unticked, and verify all five names and the queue values on the page afterwards. Unticking the queue alone changes nothing, because the status requirement binds with or without the queue. Untick the queue only for a queue-specific failure while the pull request's @@ -256,7 +274,7 @@ The script removes the JSON's explanatory `_comment` and updates `repos/ModernRelay/omnigraph/branches/main/protection`. It requires an authenticated `gh` token with repository-administration permission. -To change the policy (the four names or any other setting the JSON carries; +To change the policy (the five names or any other setting the JSON carries; which jobs block `main` is changed in `ci.yml` instead, see CI Gate): 1. Update `.github/branch-protection.json` in a PR. diff --git a/docs/dev/ci.md b/docs/dev/ci.md index 6c8bd8558..a786de88a 100644 --- a/docs/dev/ci.md +++ b/docs/dev/ci.md @@ -37,7 +37,7 @@ puts each changed path in one class: | Class | Paths | Jobs that run | |---|---|---| -| documentation | `docs/**/*.md` (`.mdx`, `.rst`, `.adoc`), `changelog.d/*.md`, `changelog.d/release.json`, the root `README.md`, `AGENTS.md`, `CLAUDE.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `LICENSE`, `LICENSE.md` | the always-on guards (`Classify Changes`, `Check AGENTS.md Links`, `Check Workflow Action Pins`, `Fix Regression Gate`, `Storage Upgrade Compatibility`, `Dependency Guard (cargo deny)`; of these only `Check AGENTS.md Links` reads documentation, through `scripts/check-docs.py`) | +| documentation | `docs/**/*.md` (`.mdx`, `.rst`, `.adoc`), `changelog.d/*.md`, `changelog.d/release.json`, the root `README.md`, `AGENTS.md`, `CLAUDE.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `LICENSE`, `LICENSE.md` | the always-on guards (`Classify Changes`, `Check AGENTS.md Links`, `Check Workflow Action Pins`, `Fix Regression Gate`, `PR Title`, `Storage Upgrade Compatibility`, `Dependency Guard (cargo deny)`; of these only `Check AGENTS.md Links` reads documentation, through `scripts/check-docs.py`) | | GQT cases | `.gqt` files anywhere under `crates/omnigraph-gqt/cases/` (recursive discovery) | the guards plus `GQ Logic Tests` (`run_gqt`) | | deployment | `Dockerfile`, `.dockerignore`, `docker/**`, `deploy/**` | the guards plus `Azure Contract Guards`, `Container Entrypoint`, `Azure Deployment Validation` (`run_deployment`) | | engine input | every other path: `crates/**` (a text fixture under a crate is source code; only the `.gqt` corpus is a class of its own), `tools/**`, `Cargo.toml`, `Cargo.lock`, `rust-toolchain.toml`, `.cargo/**`, `scripts/**`, `.github/**`, anything unlisted | every job (`run_full_ci`, which also sets `run_gqt` and `run_deployment`) | @@ -79,16 +79,17 @@ Merge queue entries trigger `ci.yml`, `gq-logic-tests.yml`, and `dst.yml` through `merge_group` (`checks_requested`). These runs check out the combined queue commit; the classifier diffs the group's base and head, so a documentation-only entry skips the same work it skips on a pull request. -Workspace tests fail fast as they do on pull requests. The PR metadata gate -stays on `pull_request_target` and reports a pass on the queue; the +Workspace tests fail fast as they do on pull requests. The PR metadata gates +stay on `pull_request_target`; on the queue `Fix Regression Gate` reports a +pass and `PR Title` checks the squash subject the entry is about to land; the vocabulary audit, Azurite, the format fence, the RustFS shards and the deployment jobs keep their pull-request or post-merge schedule and never run on the queue; the DST pinned suite runs there as a reporting context. Queue runs never publish releases or save the main branch's caches. Details: [branch-protection.md](branch-protection.md), Merge queue. -Branch protection requires four contexts: `CI Gate`, `Check Workflow Action -Pins`, `GQ Logic Tests` and `Fix Regression Gate`. `CI Gate` is the `ci_gate` +Branch protection requires five contexts: `CI Gate`, `Check Workflow Action +Pins`, `GQ Logic Tests`, `Fix Regression Gate` and `PR Title`. `CI Gate` is the `ci_gate` job of `ci.yml`: it needs `classify_changes`, `check_agents_md`, `workflow_action_pins`, `cargo_deny`, `graph_vocabulary_guard`, `fmt`, `lint`, `test`, `storage_upgrade_compatibility` and `test_aws_feature`, runs @@ -99,7 +100,9 @@ restates it. Those ten jobs are what blocks `main`; a new blocking job joins `needs:` and the table in the same pull request, and `scripts/check-merge-group-triggers.py` holds the two equal and pins the gate's wiring. `Check Workflow Action Pins` is required on its own so a -malformed gate cannot hide that validator's red. Details and the recovery +malformed gate cannot hide that validator's red. `GQ Logic Tests`, `Fix +Regression Gate` and `PR Title` are their own workflows and keep their own +names. Details and the recovery routes: [branch-protection.md](branch-protection.md), CI Gate. `Storage Upgrade Compatibility` (`storage_upgrade_compatibility` in `ci.yml`) @@ -217,6 +220,23 @@ only as data for the diff range, never checked out or executed. It runs on body edits and label changes as well as pushes, builds nothing, and runs for every change class. On the merge queue's branch it reports a pass without a check ([branch-protection.md](branch-protection.md), Merge queue). +`PR Title` (`pr-title.yml`) holds the pull request title to +`type(scope)!: description`: a type from `feat fix perf refactor docs test ci +build revert rfc release`, an optional lowercase scope list, `!` for a +breaking change, and a description that starts lowercase (a backtick or digit +also passes), carries no trailing period, and keeps the title within 100 +characters with no whitespace but the plain space; `docs(rfc):` is refused in favour of `rfc:`, and GitHub's +`Revert "…"` title in favour of `revert: …` (the rule and its examples: +[CONTRIBUTING.md](../../CONTRIBUTING.md), Pull Requests). +`scripts/check-pr-title.py` is the check; it runs its `--self-test` first and +prints one actionable failure line with the accepted shape and two examples, +as a log line and as a GitHub error annotation. It is a policy check on +`pull_request_target` like the regression gate, re-run on every title edit, +and the pull request head is never fetched. On the merge queue's branch it +checks the first line of the group commit's message, minus the ` (#N)` +suffix: the subject the queue lands, fixed when the group is built, so a +title edit after queueing cannot change what lands unchecked +([branch-protection.md](branch-protection.md), Merge queue). The `Check AGENTS.md Links` context also runs `scripts/check-docs.py`, which validates local documentation links, user/developer audience boundaries, RFC @@ -457,6 +477,8 @@ python3 scripts/check-workflow-action-pins.py python3 scripts/check-storage-upgrade-ci.py --self-test python3 scripts/check-merge-group-triggers.py --self-test python3 scripts/ci_gate.py --self-test +python3 scripts/check-pr-title.py --self-test +python3 scripts/check-pr-title.py --title "" python3 scripts/check-ci-cells.py --self-test python3 scripts/check-release-vocabulary-gates.py python3 scripts/check-container-binary-contract.py diff --git a/scripts/check-pr-title.py b/scripts/check-pr-title.py new file mode 100755 index 000000000..57c14f0d7 --- /dev/null +++ b/scripts/check-pr-title.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Every pull request title is a Conventional Commits subject. + +Every merge into `main` is a squash through the merge queue, and the squash +subject is the pull request title, so the title is the one line `git log` +and the release tooling keep for the change. This check holds it to +`type(scope)!: description`: one type from TYPES, an optional lowercase +scope list in parentheses, `!` for a breaking change, a colon and one space, +then a description that starts with a non-capital, carries no trailing +period, and keeps the whole title within MAX_LEN characters; the only +whitespace a title may contain is the plain space. GitHub's own +`Revert "…"` title and the `docs(rfc):` spellings are refused with the one +accepted form in the message, so `git log` greps as one history. + +`--title TITLE` checks a pull request title. `--squash-subject MESSAGE` +checks the commit message the merge queue is about to land: the first line, +with GitHub's ` (#N)` suffix removed, is the title. A failure prints one +line naming what failed, the accepted shape and two examples, and exits 1. +`--self-test` runs the accepted and refused fixtures, including the untyped +titles that reached `main` before this check existed. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys + +TYPES = ( + "feat", + "fix", + "perf", + "refactor", + "docs", + "test", + "ci", + "build", + "revert", + "rfc", + "release", +) +MAX_LEN = 100 +SHAPE = "type(scope)!: description" +EXAMPLES = ( + "feat(server): return exact merge publication receipts", + "fix(merge): decide fast_forward by the target's state", +) + +TITLE = re.compile(r"^(?P[A-Za-z]+)(?:\((?P[^()]*)\))?(?P!?)(?P:?)(?P.*)$") +SCOPE = re.compile(r"^[a-z0-9_-]+(?:,[a-z0-9_-]+)*$") +PR_SUFFIX = re.compile(r" \(#\d+\)$") +GITHUB_REVERT = re.compile(r'^Revert\s+"') + + +def failure(title: str) -> str | None: + """The reason `title` is refused, or None when it is accepted.""" + if title != title.strip(): + return "leading or trailing whitespace" + if not title: + return "empty title" + if any(c.isspace() and c != " " for c in title): + return "a tab, line break or other non-space whitespace in the title" + if len(title) > MAX_LEN: + return f"{len(title)} characters; at most {MAX_LEN}" + if GITHUB_REVERT.match(title): + return 'GitHub\'s `Revert "…"` title; use `revert: `' + m = TITLE.match(title) + if m is None or not m.group("colon"): + return f"no `type:` prefix; the shape is `{SHAPE}`" + type_ = m.group("type") + if type_ not in TYPES: + if type_.lower() in TYPES: + return f"type `{type_}` is spelled in capitals; use `{type_.lower()}`" + return f"unknown type `{type_}`; one of {', '.join(TYPES)}" + scope = m.group("scope") + if scope is not None: + if type_ == "docs" and scope in ("rfc", "rfcs"): + return f"`docs({scope}):` for an RFC; use `rfc:`" + if not SCOPE.fullmatch(scope): + return f"scope `({scope})` is not lowercase `a-z0-9_-` names separated by commas" + rest = m.group("rest") + if not rest.strip(): + return "empty description" + if not rest.startswith(" ") or rest[1:2].isspace(): + return "exactly one space after the colon" + desc = rest[1:] + if desc[0].isupper(): + return f"description starts with a capital `{desc[0]}`; start lowercase (a backtick or digit also passes)" + if desc.endswith("."): + return "description ends with a period" + return None + + +def squash_title(message: str) -> str: + """The pull request title inside a merge-queue commit message.""" + subject = message.splitlines()[0] if message.strip() else "" + return PR_SUFFIX.sub("", subject.strip()) + + +def message(title: str, reason: str) -> str: + return ( + f"PR title refused: {reason}.\n" + f" title: {title!r}\n" + f" shape: `{SHAPE}` with type one of {', '.join(TYPES)}; `(scope)` and `!` optional;\n" + f" description lowercase-first, no trailing period, at most {MAX_LEN} characters\n" + f" examples: {EXAMPLES[0]}\n" + f" {EXAMPLES[1]}\n" + f" rule: CONTRIBUTING.md, Pull Requests" + ) + + +def check(title: str) -> int: + reason = failure(title) + if reason is None: + print(f"PR title accepted: {title}") + return 0 + text = message(title, reason) + print(text) + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error title=PR Title::{reason}; the shape is `{SHAPE}` (CONTRIBUTING.md, Pull Requests)") + return 1 + + +ACCEPTED = ( + "feat(server): return exact merge publication receipts", + "fix(merge): decide fast_forward by the target's state", + "refactor(blob,schema)!: remove --allow-data-loss", + "docs: correct the ledger v2 upgrade note", + "rfc: search plan truth on engine v2", + "fix(server): follow RFC 9110 for 503 retries", + "release: v0.12.0", + "revert: drop list membership", + "docs: fix a broken link in the upgrade guide", + "test(gq): cover inherited indexes after fast-forward", + "ci: run slow GQT cases in a nightly workflow", + "build: bump lance to 11.0.1", + "fix: reject `DateTime` digits", + "build: bump Lance to 11.0.1", + "perf(catalog): release settled commits to immutable `__history` extents", + "feat(gqt): add `--- concurrent` block, ordered sessions on one handle under DST", + "fix!: 2-phase cursor keeps wide rows out of SortExec", + "feat(engine): " + "x" * (MAX_LEN - len("feat(engine): ")), +) + +REFUSED = ( + # Untyped titles that reached `main` before the check. + ("fix (#898)", "no `type:` prefix"), + ("Update Readme", "no `type:` prefix"), + ("Enable server-owned v2 deployments without restart", "no `type:` prefix"), + ("detached table commits replace recovery sidecars", "no `type:` prefix"), + ("imp", "no `type:` prefix"), + ("review", "no `type:` prefix"), + ("Check docs conflict markers", "no `type:` prefix"), + ("Clarify infrastructure support in README", "no `type:` prefix"), + ("Merge pull request #680 from ModernRelay/codex/self-service-cluster-lifecycle", "no `type:` prefix"), + ("Merge remote-tracking branch 'origin/main' into codex/revert-663-actor-provenance", "no `type:` prefix"), + ("RFC 0066: Server lifecycle and online deployment", "no `type:` prefix"), + ("compiler: the diagnostics contract for refused queries", "unknown type `compiler`"), + ("engine: shared schema gate and the write critical section", "unknown type `engine`"), + ("snapshot: read the storage-format stamp from the snapshot it returns", "unknown type `snapshot`"), + ("cli: extend managed deadlines and prepare bounded transports", "unknown type `cli`"), + ("auth: use cluster identities and applied policy for authorization", "unknown type `auth`"), + ("bench: concurrent-writes throughput diagnostic", "unknown type `bench`"), + ("merge: integrate main and allocate managed lifecycle RFC 0061", "unknown type `merge`"), + ("chore(release): prepare notes for v0.13.1", "unknown type `chore`"), + ("Governance: issue-first intake, Discussions retired", "unknown type `Governance`"), + ("GQ: add edge alternation and wildcard traversal", "unknown type `GQ`"), + ("RFC: Compatibility surfaces", "spelled in capitals"), + ("docs(rfc): align RFC 0068 with detached-only tables", "use `rfc:`"), + ("docs(rfcs): name new RFCs by creation date and slug", "use `rfc:`"), + ("rfc: RFC 0047, search plan truth on engine v2", "starts with a capital"), + # Shapes the rule refuses by construction. + ("", "empty title"), + (" feat: leading space", "whitespace"), + ("feat: trailing space ", "whitespace"), + ("feat:", "empty description"), + ("feat(server)!:", "empty description"), + ("feat:no space", "exactly one space"), + ("feat: two spaces", "exactly one space"), + ("fix: \tCapital description", "non-space whitespace"), + ("fix:  Capital description", "non-space whitespace"), + ("fix: \rCapital description", "non-space whitespace"), + ("fix:\ttab after the colon", "non-space whitespace"), + ("feat: two\nlines", "non-space whitespace"), + ("feat(server\n): newline in the scope", "non-space whitespace"), + ("feat(server ): no-break space in the scope", "non-space whitespace"), + ("feat(): empty scope", "scope `()`"), + ("feat(Server): capital scope", "scope `(Server)`"), + ("feat(server, api): space in scope", "scope `(server, api)`"), + ("feat(server)!!: double bang", "no `type:` prefix"), + ("feat(server) : space before colon", "no `type:` prefix"), + ("Feat: capital type", "spelled in capitals"), + ("FIX: capital type", "spelled in capitals"), + ("feat: Capital description", "starts with a capital `C`"), + ("feat: ends with a period.", "ends with a period"), + ("chore: tidy", "unknown type `chore`"), + ("style: reformat", "unknown type `style`"), + ("WIP", "no `type:` prefix"), + ("[WIP] feat: draft", "no `type:` prefix"), + ('Revert "feat(gq): add list membership"', "use `revert: `"), + ("feat(engine): " + "x" * (MAX_LEN + 1 - len("feat(engine): ")), f"at most {MAX_LEN}"), +) + +SQUASH_SUBJECTS = ( + ("feat(server): own writes, bound admission, and coordinate shutdown (#824)\n\n* body line\n", "feat(server): own writes, bound admission, and coordinate shutdown"), + ("release: v0.12.0 (#877)", "release: v0.12.0"), + ("fix (#898)", "fix"), + ("fix: keep a (#12) inside the text (#900)", "fix: keep a (#12) inside the text"), + ("Update Readme (#881)\n", "Update Readme"), + ("", ""), +) + + +def self_test() -> int: + failures: list[str] = [] + for title in ACCEPTED: + reason = failure(title) + if reason is not None: + failures.append(f"accepted fixture refused: {title!r}: {reason}") + for title, expected in REFUSED: + reason = failure(title) + if reason is None: + failures.append(f"refused fixture accepted: {title!r}") + elif expected is not None and expected not in reason: + failures.append(f"refused fixture {title!r}: expected {expected!r} in {reason!r}") + for text, expected in SQUASH_SUBJECTS: + got = squash_title(text) + if got != expected: + failures.append(f"squash subject {text!r}: expected {expected!r}, got {got!r}") + if failure(squash_title(SQUASH_SUBJECTS[0][0])) is not None: + failures.append("squash subject of an accepted title is not itself accepted") + if SCOPE.fullmatch("server\n") is not None or SCOPE.fullmatch("server") is None: + failures.append("scope grammar must match the whole scope and nothing else") + sample = message("WIP", "no `type:` prefix") + for needle in (SHAPE, EXAMPLES[0], EXAMPLES[1], "CONTRIBUTING.md"): + if needle not in sample: + failures.append(f"failure message lacks {needle!r}") + if failures: + print("\n".join(failures)) + return 1 + print(f"self-test ok ({len(ACCEPTED)} accepted, {len(REFUSED)} refused, {len(SQUASH_SUBJECTS)} squash subjects)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--title", help="a pull request title") + parser.add_argument("--squash-subject", help="a merge-queue commit message; its first line minus ` (#N)` is the title") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + if args.self_test: + return self_test() + if (args.title is None) == (args.squash_subject is None): + parser.error("exactly one of --title or --squash-subject is required unless --self-test") + title = args.title if args.title is not None else squash_title(args.squash_subject) + return check(title) + + +if __name__ == "__main__": + sys.exit(main()) From eecee4e8635ed5b372bff17c537016dddb28849c Mon Sep 17 00:00:00 2001 From: azim afroozeh <13484327+azimafroozeh@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:00:21 +0200 Subject: [PATCH 2/2] fix --- scripts/check-merge-group-triggers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/check-merge-group-triggers.py b/scripts/check-merge-group-triggers.py index 780715235..88799f4b9 100644 --- a/scripts/check-merge-group-triggers.py +++ b/scripts/check-merge-group-triggers.py @@ -25,7 +25,7 @@ aliases. The second half pins the `CI Gate` wiring (docs/dev/branch-protection.md): the -policy's contexts are exactly the four in `OWNERS`, each owned by the named +policy's contexts are exactly the five in `OWNERS`, each owned by the named job of the named workflow; `ci.yml`'s `ci_gate` job runs under `if: always()` with no `continue-on-error`, its `needs:` equals the job table in `scripts/ci_gate.py`, every needed job uses a block mapping without @@ -62,6 +62,7 @@ VALIDATOR_CONTEXT: (CI_WORKFLOW, VALIDATOR_JOB), "GQ Logic Tests": (".github/workflows/gq-logic-tests.yml", "gq_logic_tests"), "Fix Regression Gate": (".github/workflows/fix-regression-gate.yml", "fix_regression_gate"), + "PR Title": (".github/workflows/pr-title.yml", "pr_title"), } GATE_COMMAND = "python3 scripts/ci_gate.py" GATE_ENV = { @@ -394,7 +395,7 @@ def validate_gate(workflows: dict[str, str], contexts: list[str], table: dict[st for context in sorted(expected - set(contexts)): failures.append(f"policy lacks required context {context!r}") for context in sorted(set(contexts) - expected): - failures.append(f"policy requires {context!r}, which is not one of the four gate-era contexts") + failures.append(f"policy requires {context!r}, which is not one of the five gate-era contexts") for context, (path, job) in OWNERS.items(): if path not in parsed: failures.append(f"{path}: missing, must own {context!r}") @@ -617,12 +618,13 @@ def b_mg(config: str) -> str: """ GATE_GQ = "on:\n pull_request:\n merge_group:\njobs:\n gq_logic_tests:\n name: GQ Logic Tests\n if: always()\n runs-on: u\n" GATE_FIX = "on:\n pull_request_target:\n merge_group:\njobs:\n fix_regression_gate:\n name: Fix Regression Gate\n runs-on: u\n" +GATE_TITLE = "on:\n pull_request_target:\n merge_group:\njobs:\n pr_title:\n name: PR Title\n runs-on: u\n" GATE_CONTEXTS = list(OWNERS) def self_test_gate() -> None: def run(ci: str = GATE_CI, contexts: list[str] = GATE_CONTEXTS, table: dict[str, str] = GATE_TABLE, **extra: str) -> list[str]: - workflows = {CI_WORKFLOW: ci, OWNERS["GQ Logic Tests"][0]: GATE_GQ, OWNERS["Fix Regression Gate"][0]: GATE_FIX, **extra} + workflows = {CI_WORKFLOW: ci, OWNERS["GQ Logic Tests"][0]: GATE_GQ, OWNERS["Fix Regression Gate"][0]: GATE_FIX, OWNERS["PR Title"][0]: GATE_TITLE, **extra} return validate(workflows, contexts) + validate_gate(workflows, contexts, table) def refused(fragment: str, *args, **kw) -> None: @@ -645,7 +647,10 @@ def validator(old: str, new: str) -> str: accepted() refused("policy lacks required context 'CI Gate'", GATE_CI, [c for c in GATE_CONTEXTS if c != GATE_CONTEXT]) refused("policy lacks required context 'Check Workflow Action Pins'", GATE_CI, [c for c in GATE_CONTEXTS if c != VALIDATOR_CONTEXT]) - refused("not one of the four gate-era contexts", GATE_CI, [*GATE_CONTEXTS, "Test Workspace"]) + refused("not one of the five gate-era contexts", GATE_CI, [*GATE_CONTEXTS, "Test Workspace"]) + refused("policy lacks required context 'PR Title'", GATE_CI, [c for c in GATE_CONTEXTS if c != "PR Title"]) + refused("must be owned by job 'pr_title'", **{OWNERS["PR Title"][0]: GATE_TITLE.replace("pr_title:", "title:")}) + refused("no merge_group in the on: mapping", **{OWNERS["PR Title"][0]: GATE_TITLE.replace(" merge_group:\n", "")}) refused("must be owned by job 'ci_gate'", gate(" name: CI Gate\n", " name: CI gate\n")) refused("must be owned by job 'ci_gate'", GATE_CI.replace(" ci_gate:\n", " ci_gate2:\n")) refused("must be owned by job 'workflow_action_pins'", validator(" name: Check Workflow Action Pins\n", " name: Check Pins\n"))