From 69157b963505d51b6652f99b8bd2ab65ae49fcc0 Mon Sep 17 00:00:00 2001 From: Talha Amjad Date: Mon, 14 Sep 2026 14:03:48 +0500 Subject: [PATCH 1/4] ci(release): include GitHub contributor attribution --- .github/release.yml | 4 +++ .github/workflows/release-candidate.yml | 15 ++++++++++- .github/workflows/release-to-pypi.yml | 14 ++++++++++ docs/releasing.md | 34 ++++++++++++++++++++++++- tests/test_release_notes.py | 28 +++++++++++++++++++- utils/render_release_notes.py | 30 ++++++++++++++++++++-- 6 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 .github/release.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..f621731b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,4 @@ +changelog: + exclude: + authors: + - github-actions[bot] diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 9ca543b1..d10ec384 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -56,6 +56,7 @@ jobs: source_tree: ${{ steps.source.outputs.tree }} tag: ${{ steps.contract.outputs.tag }} version: ${{ steps.contract.outputs.version }} + previous_tag: ${{ steps.source.outputs.previous_tag }} steps: - uses: actions/checkout@v7 with: @@ -85,6 +86,15 @@ jobs: [[ "$(jq -r .head.sha <<< "$pr")" == "$HEAD_SHA" ]] [[ "$(jq -r .head.ref <<< "$pr")" == release-please--branches--* ]] echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + manifest=.release-please-manifest.json + if [[ "$TARGET_BRANCH" == "release" ]]; then + manifest=.release-please-manifest.stable.json + fi + previous_version="$(git show "$BASE_SHA:$manifest" | jq -er '.["."]')" + previous_tag="v$previous_version" + git show-ref --verify --quiet "refs/tags/$previous_tag" + git merge-base --is-ancestor "refs/tags/$previous_tag" "$BASE_SHA" + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - name: Validate the combined version contract id: contract @@ -152,6 +162,7 @@ jobs: CONTROL_DIGEST: ${{ needs.containers.outputs.control_digest }} HEAD_SHA: ${{ inputs.head_sha }} PRODUCT_DIGEST: ${{ needs.containers.outputs.product_digest }} + PREVIOUS_TAG: ${{ needs.contract.outputs.previous_tag }} SOURCE_TREE: ${{ needs.contract.outputs.source_tree }} TAG: ${{ needs.contract.outputs.tag }} VERSION: ${{ needs.contract.outputs.version }} @@ -163,6 +174,7 @@ jobs: --arg channel "$CHANNEL" \ --arg control_digest "$CONTROL_DIGEST" \ --arg product_digest "$PRODUCT_DIGEST" \ + --arg previous_tag "$PREVIOUS_TAG" \ --arg source_sha "$HEAD_SHA" \ --arg source_tree "$SOURCE_TREE" \ --arg tag "$TAG" \ @@ -170,7 +182,8 @@ jobs: --arg worker_digest "$WORKER_DIGEST" \ '{candidate_run_id: $candidate_run_id, channel: $channel, source_sha: $source_sha, source_tree: $source_tree, tag: $tag, - version: $version, containers: {product: $product_digest, + version: $version, previous_tag: $previous_tag, + containers: {product: $product_digest, control: $control_digest, worker: $worker_digest}}' \ > release-candidate.json diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index 0a0feb0e..223d0135 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -33,6 +33,7 @@ jobs: product_digest: ${{ steps.candidate.outputs.product_digest }} source_sha: ${{ steps.source.outputs.sha }} version: ${{ steps.contract.outputs.version }} + previous_tag: ${{ steps.candidate.outputs.previous_tag }} worker_digest: ${{ steps.candidate.outputs.worker_digest }} steps: - uses: actions/checkout@v7 @@ -104,10 +105,16 @@ jobs: [[ "$candidate_tree" == "$release_tree" ]] [[ "$recorded_tree" == "$release_tree" ]] + previous_tag="$(jq -er '.previous_tag | select(type == "string" and startswith("v"))' "$candidate")" + [[ "$previous_tag" != "$TAG" ]] + git show-ref --verify --quiet "refs/tags/$previous_tag" + git merge-base --is-ancestor "refs/tags/$previous_tag" HEAD + { echo "product_digest=$(jq -r .containers.product "$candidate")" echo "control_digest=$(jq -r .containers.control "$candidate")" echo "worker_digest=$(jq -r .containers.worker "$candidate")" + echo "previous_tag=$previous_tag" } >> "$GITHUB_OUTPUT" - name: Prepare release assets and checksums @@ -221,8 +228,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ inputs.tag }} VERSION: ${{ needs.resolve.outputs.version }} + PREVIOUS_TAG: ${{ needs.resolve.outputs.previous_tag }} shell: bash run: | + gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$TAG" \ + -f previous_tag_name="$PREVIOUS_TAG" \ + -f configuration_file_path=.github/release.yml \ + --jq .body > github-contribution-notes.md gh release view "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --json body \ @@ -231,6 +244,7 @@ jobs: --assets release-assets \ --channel "$CHANNEL" \ --existing release-please-notes.md \ + --github-notes github-contribution-notes.md \ --output release-notes.md \ --repository "$GITHUB_REPOSITORY" \ --tag "$TAG" \ diff --git a/docs/releasing.md b/docs/releasing.md index 28b350e1..a99215bb 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -101,12 +101,44 @@ candidate have the same version and Git tree. It then: 3. uploads the Python distributions, three Desktop packages, and `SHA256SUMS` to the GitHub release; 4. renders `.github/release-intro.md` with the exact asset names above the - generated changelog; and + generated changelog and adds GitHub's contributor notes; and 5. publishes the GitHub release as a prerelease or the latest stable release. Publication reuses the candidate artifacts. It does not rebuild them after the release pull request is merged. +### Contributor attribution + +Release Please continues to own version selection and the product changelog. +An expandable **Contributors and pull requests** section below that changelog +contains GitHub's native generated notes: PR author credits, any **New +Contributors** entries, and the full comparison link. The output is preserved +as Markdown rather than parsed into a separate author list. It credits PR +contributions, not every reviewer, issue reporter, or co-author. + +The candidate records the previous tag from its target branch's release +manifest at the verified base revision. Beta uses +`.release-please-manifest.json`; stable uses +`.release-please-manifest.stable.json`. Both candidate validation and publication +check that the previous tag exists and is an ancestor of the selected source. +Publication supplies this explicit range to GitHub, instead of asking it to +choose the latest release. `.github/release.yml` excludes release automation +by `github-actions[bot]`; human documentation and test contributions remain +eligible for credit. + +A contributor can appear as new in both a beta and its later stable release +when both comparison ranges include their first contribution. This follows +GitHub's attribution behavior; VidXP does not track a separate announcement +history. Nightly TestPyPI packages do not have a GitHub release or this section. + +GitHub generates the contribution notes before the final asset upload and +release-page edit. If generation fails, publication stops at that step; PyPI +and container publication may already have completed. Retry the same workflow +after resolving the error. Rendering replaces the previous attribution block +without duplicating it or changing the product changelog. Notes are regenerated +on retry, so later PR-title edits can be reflected even though the tag range +stays fixed. + The Desktop installers contain the candidate's tested VidXP wheel. This allows managed setup to be tested before that wheel is public. Selected dependencies still resolve from production PyPI; TestPyPI is used only for nightly package diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index a36f063c..0da84eab 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -24,7 +24,9 @@ def setUp(self): ROOT / ".github" / "release-intro.md" ).read_text(encoding="utf-8") - def render(self, notes: str, channel: str = "stable") -> str: + def render( + self, notes: str, channel: str = "stable", github_notes: str | None = None + ) -> str: return render( template=self.template, existing_notes=notes, @@ -33,6 +35,7 @@ def render(self, notes: str, channel: str = "stable") -> str: tag="v0.4.0-b", version="0.4.0-b", channel=channel, + github_notes=github_notes, ) def test_release_page_links_exact_assets_before_changelog(self): @@ -70,6 +73,29 @@ def test_rejects_an_ambiguous_platform_asset(self): with self.assertRaisesRegex(ValueError, "exactly one .*exe"): self.render("changes") + def test_contribution_notes_are_replaced_on_retry_without_changelog_loss(self): + notes = "## New Contributors\n* @first made their first contribution in #128" + first = self.render("* Improved indexing.", github_notes=notes) + self.assertEqual(first, self.render(first, github_notes=notes)) + self.assertEqual(first, self.render(first)) + + updated = self.render(first, github_notes="## What's Changed\n* Fix by @second") + self.assertIn("* Improved indexing.", updated) + self.assertNotIn("@first", updated) + self.assertEqual(updated.count("@second"), 1) + self.assertEqual(updated.count("
"), 1) + + def test_native_notes_without_new_contributors_are_preserved(self): + notes = "## What's Changed\n* Fix by @returning\n\n**Full Changelog**: link" + result = self.render("* Product fix.", channel="beta", github_notes=notes) + self.assertIn(notes, result) + self.assertIn("Beta release", result) + self.assertNotIn("## New Contributors", result) + + def test_empty_generated_notes_stop_rendering(self): + with self.assertRaisesRegex(ValueError, "must not be empty"): + self.render("* Product fix.", github_notes=" \n") + if __name__ == "__main__": unittest.main() diff --git a/utils/render_release_notes.py b/utils/render_release_notes.py index 2c667dbe..a83835e7 100644 --- a/utils/render_release_notes.py +++ b/utils/render_release_notes.py @@ -8,6 +8,8 @@ PAGE_START = "" CHANGELOG_START = "" PAGE_END = "" +ATTRIBUTION_START = "" +ATTRIBUTION_END = "" COMPOSED_PAGE = re.compile( rf"{re.escape(PAGE_START)}.*?{re.escape(CHANGELOG_START)}" rf"(?P.*?){re.escape(PAGE_END)}", @@ -36,7 +38,7 @@ def _download_url(repository: str, tag: str, asset: Path) -> str: def _original_changelog(notes: str) -> str: match = COMPOSED_PAGE.fullmatch(notes.strip()) if match: - return match.group("changelog").strip() + return match.group("changelog").split(ATTRIBUTION_START, 1)[0].strip() return notes.strip() @@ -49,6 +51,7 @@ def render( tag: str, version: str, channel: str, + github_notes: str | None = None, ) -> str: if channel not in {"beta", "stable"}: raise ValueError(f"unsupported release channel: {channel}") @@ -89,10 +92,27 @@ def render( if not changelog: changelog = "No user-facing changes were listed for this release." + attribution = "" + if github_notes is not None: + if not github_notes.strip(): + raise ValueError("GitHub contribution notes must not be empty") + attribution = ( + f"\n{ATTRIBUTION_START}\n" + "
\nContributors and pull requests\n\n" + f"{github_notes.strip()}\n\n
\n{ATTRIBUTION_END}\n" + ) + elif ATTRIBUTION_START in existing_notes and ATTRIBUTION_END in existing_notes: + # Preserve credits when an existing page is rendered without new notes. + attribution = ( + f"\n{ATTRIBUTION_START}" + + existing_notes.split(ATTRIBUTION_START, 1)[1].split(ATTRIBUTION_END, 1)[0] + + f"{ATTRIBUTION_END}\n" + ) + return ( f"{PAGE_START}\n{intro}\n\n" f"## What changed\n\n{CHANGELOG_START}\n" - f"{changelog}\n{PAGE_END}\n" + f"{changelog}\n{attribution}{PAGE_END}\n" ) @@ -103,6 +123,7 @@ def main() -> None: parser.add_argument("--assets", type=Path, required=True) parser.add_argument("--channel", choices=("beta", "stable"), required=True) parser.add_argument("--existing", type=Path, required=True) + parser.add_argument("--github-notes", type=Path) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--repository", required=True) parser.add_argument("--tag", required=True) @@ -118,6 +139,11 @@ def main() -> None: tag=args.tag, version=args.version, channel=args.channel, + github_notes=( + args.github_notes.read_text(encoding="utf-8") + if args.github_notes is not None + else None + ), ) args.output.write_text(rendered, encoding="utf-8") From eb5cecbe96b79d09e3997ed5ab80945c629fa098 Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 26 Oct 2026 14:49:21 +0500 Subject: [PATCH 2/4] fix(desktop): find the beta runtime wheel by its PEP 440 version Release Please writes 1.0.0-b, which Python names 1.0.0b0, so every release candidate build has failed to find the wheel since 28 Aug. --- desktop/src-tauri/build.rs | 7 ++++- desktop/src-tauri/src/lib.rs | 2 ++ desktop/src-tauri/src/wheel_version.rs | 38 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/wheel_version.rs diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 174f6707..80d4fc4d 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,6 @@ +#[path = "src/wheel_version.rs"] +mod wheel_version; + fn main() { use sha2::{Digest, Sha256}; use std::fmt::Write; @@ -29,7 +32,9 @@ fn main() { let package_version = manifest["package_version"] .as_str() .expect("runtime manifest must contain package_version"); - let wheel_version = package_version.replace("-b.", "b").replace("-b", "b"); + let wheel_version = wheel_version::wheel_version(package_version).unwrap_or_else(|| { + panic!("runtime manifest package_version {package_version:?} has no known wheel version") + }); let wheel_prefix = format!("{}-{wheel_version}-", package_name.replace('-', "_")); let distribution_directory = Path::new("../..").join("dist"); let wheels = std::fs::read_dir(&distribution_directory) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0e897430..650096a9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -34,6 +34,8 @@ mod media_setup; mod premiere_integration; mod query_setup; mod target_profiles; +#[cfg(test)] +mod wheel_version; use activation::{ActivationRecovery, ActivationStage, activation_recovery}; use lifecycle::{ diff --git a/desktop/src-tauri/src/wheel_version.rs b/desktop/src-tauri/src/wheel_version.rs new file mode 100644 index 00000000..e32272ec --- /dev/null +++ b/desktop/src-tauri/src/wheel_version.rs @@ -0,0 +1,38 @@ +// Release Please writes `X.Y.Z-b` and `X.Y.Z-b.N`; wheel names carry the PEP 440 form. +pub(crate) fn wheel_version(version: &str) -> Option { + let Some((release, prerelease)) = version.split_once("-b") else { + return Some(version.to_owned()); + }; + if prerelease.is_empty() { + return Some(format!("{release}b0")); + } + let number = prerelease.strip_prefix('.')?; + if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + Some(format!("{release}b{}", number.parse::().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::wheel_version; + + #[test] + fn release_please_versions_match_python_wheel_names() { + for (version, expected) in [ + ("0.4.0", "0.4.0"), + ("1.0.0-b", "1.0.0b0"), + ("0.4.0-b.3", "0.4.0b3"), + ("1.0.0-b.12", "1.0.0b12"), + ] { + assert_eq!(wheel_version(version).as_deref(), Some(expected), "{version}"); + } + } + + #[test] + fn unexpected_prerelease_shapes_are_rejected() { + for version in ["1.0.0-b.", "1.0.0-beta", "1.0.0-b.x", "1.0.0-b1"] { + assert_eq!(wheel_version(version), None, "{version}"); + } + } +} From 6f09caede9b361d105eee244425545c3d3ef2ee1 Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 26 Oct 2026 14:50:05 +0500 Subject: [PATCH 3/4] ci: build desktop installers on PRs only when their inputs change Release candidates already build and sign the installers after every merge to main, so PRs touching only src/, tests/, other utils/ scripts or other workflows no longer spend ~20 minutes on the three installer builds. --- tests/test_ci_scope.py | 41 ++++++++++++++++++++++++++--------------- utils/ci_scope.py | 9 ++++++--- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/tests/test_ci_scope.py b/tests/test_ci_scope.py index bf7ab97b..04e5caa8 100644 --- a/tests/test_ci_scope.py +++ b/tests/test_ci_scope.py @@ -39,16 +39,22 @@ def test_product_and_workflow_changes_validate_containers(self): self.assertTrue(scope.run_suite) self.assertTrue(scope.run_container) - def test_desktop_uses_stable_product_and_packaging_boundaries(self): + def test_desktop_installers_build_only_for_what_they_package(self): for path in ( "desktop/src/App.tsx", - "src/vidxp/settings.py", - "tests/test_packaging.py", "utils/build_package.sh", + "utils/build-requirements.txt", + "utils/fix_readme_links.py", + "utils/normalize_sdist.py", "pyproject.toml", "uv.lock", + "MANIFEST.in", + "setup.py", + "LICENSE", + ".github/workflows/ci.yml", ".github/workflows/desktop.yml", "plugins/vidxp/skills/vidxp-ingest-video/SKILL.md", + "premiere/src/panel.ts", ): with self.subTest(path=path): self.assertTrue(classify([path]).run_desktop) @@ -58,6 +64,11 @@ def test_desktop_uses_stable_product_and_packaging_boundaries(self): "docs/releasing.md", ".agents/plugins/marketplace.json", "web/upload-page/src/app.js", + "src/vidxp/settings.py", + "tests/test_packaging.py", + "utils/ci_scope.py", + ".github/workflows/release-please.yml", + ".github/workflows/future.yml", ): with self.subTest(path=path): self.assertFalse(classify([path]).run_desktop) @@ -118,27 +129,27 @@ def test_benchmark_python_keeps_suite_without_product_builds(self): self.assertEqual(classify(paths), Scope(True, False, False, False)) def test_benchmark_changes_do_not_hide_shared_or_unknown_changes(self): - for path in ( - "src/vidxp/core/runner.py", - "src/vidxp/cli.py", - "src/vidxp/benchmarks/requirements.txt", - "pyproject.toml", - "uv.lock", - "MANIFEST.in", - "utils/ci_scope.py", - ".github/workflows/future.yml", - "future-product/component.rs", + for path, run_desktop in ( + ("src/vidxp/core/runner.py", False), + ("src/vidxp/cli.py", False), + ("src/vidxp/benchmarks/requirements.txt", False), + ("pyproject.toml", True), + ("uv.lock", True), + ("MANIFEST.in", True), + ("utils/ci_scope.py", False), + (".github/workflows/future.yml", False), + ("future-product/component.rs", True), ): with self.subTest(path=path): self.assertEqual( classify(["src/vidxp/benchmarks/latency.py", path]), - Scope(True, True, True, True), + Scope(True, True, run_desktop, True), ) def test_unrecognized_tests_keep_existing_validation(self): self.assertEqual( classify(["tests/test_new_benchmark_runtime.py"]), - Scope(True, False, True, True), + Scope(True, False, False, True), ) def test_forced_validation_overrides_benchmark_exemption(self): diff --git a/utils/ci_scope.py b/utils/ci_scope.py index 45cd1967..a1985d17 100644 --- a/utils/ci_scope.py +++ b/utils/ci_scope.py @@ -44,15 +44,18 @@ def _is_container_neutral(path: str) -> bool: def _affects_desktop(path: str) -> bool: - if path.startswith(".github/workflows/"): - return True - return path.startswith(("desktop/", "plugins/", "src/", "tests/", "utils/")) or path in { + # Release candidates build installers after every merge; PRs only for what they package. + return path.startswith(("desktop/", "plugins/")) or path in { ".github/workflows/ci.yml", ".github/workflows/desktop.yml", "LICENSE", "MANIFEST.in", "pyproject.toml", "setup.py", + "utils/build-requirements.txt", + "utils/build_package.sh", + "utils/fix_readme_links.py", + "utils/normalize_sdist.py", "uv.lock", } From a4c977ca5f63ce15afe7eee5ff5c0884942b315d Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 26 Oct 2026 14:52:52 +0500 Subject: [PATCH 4/4] ci: check that pull request titles are Conventional Commits Squash merges turn the title into the commit Release Please reads; 17 of the 191 commits on main since July had other titles and were left out of the changelog and version. --- .github/workflows/pr-title.yml | 30 ++++++++++++++++ docs/CONTRIBUTING.md | 3 +- tests/test_pr_title.py | 43 ++++++++++++++++++++++ utils/pr_title.py | 65 ++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-title.yml create mode 100644 tests/test_pr_title.py create mode 100644 utils/pr_title.py diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 00000000..cae63099 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,30 @@ +name: PR title + +on: + pull_request: + branches: + - main + - release + types: + - opened + - edited + - synchronize + - reopened + +permissions: + contents: read + +jobs: + pr-title: + name: pr-title + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: utils/pr_title.py + sparse-checkout-cone-mode: false + + - name: Check the Conventional Commit title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: python3 utils/pr_title.py -- "$PR_TITLE" diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0d545b4f..6333de10 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -221,7 +221,8 @@ concrete: - **Validation:** list exact commands and the real boundaries exercised. Do not replace results with “tests passed.” -Use a Conventional Commit title. Examples: +Use a Conventional Commit title; the `pr-title` check fails until it matches. +Examples: ```text feat(mcp): add clip artifact discovery diff --git a/tests/test_pr_title.py b/tests/test_pr_title.py new file mode 100644 index 00000000..93258992 --- /dev/null +++ b/tests/test_pr_title.py @@ -0,0 +1,43 @@ +import unittest +from pathlib import Path + +from utils.pr_title import EXAMPLES, title_problem + +ROOT = Path(__file__).resolve().parents[1] + + +class PrTitleTests(unittest.TestCase): + def test_conventional_titles_pass(self): + for title in ( + *EXAMPLES, + "build(deps): bump the actions group with 3 updates", + "chore(main): release 1.0.0-b", + "feat(search)!: drop the legacy query endpoint", + "refactor!: rename the index command", + "fix(fusion): bound moment components to shared overlapping interval (#87)", + ): + with self.subTest(title=title): + self.assertIsNone(title_problem(title)) + + def test_titles_release_please_would_skip_fail(self): + for title, problem in ( + ("Show media state in list command (#112)", '": "'), + ("feat:add clips", '": "'), + ("Fix: keep search form submittable", "type must be one of"), + ("feature(mcp): add clips", "type must be one of"), + ("fix (mcp): add clips", "type must be one of"), + ("fix(): add clips", "scope in parentheses is empty"), + ("fix(mcp): ", "subject"), + ): + with self.subTest(title=title): + self.assertIn(problem, title_problem(title) or "") + + def test_failure_examples_are_the_contributing_guide_examples(self): + guide = (ROOT / "docs" / "CONTRIBUTING.md").read_text(encoding="utf-8") + for example in EXAMPLES: + with self.subTest(example=example): + self.assertIn(f"\n{example}\n", guide) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/pr_title.py b/utils/pr_title.py new file mode 100644 index 00000000..56b87b6f --- /dev/null +++ b/utils/pr_title.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import argparse +import re +import sys + +# The squash-merge title becomes the commit Release Please reads; see docs/CONTRIBUTING.md. +TYPES = ( + "feat", + "fix", + "docs", + "perf", + "deps", + "revert", + "chore", + "refactor", + "test", + "build", + "ci", + "style", +) +EXAMPLES = ( + "feat(mcp): add clip artifact discovery", + "fix(frontend): keep search form submittable", + "docs: clarify local installation profiles", + "ci(release): verify signed macOS installers", +) +HEAD = re.compile(r"(?P[^(!:]*)(?P\([^()]*\))?!?") + + +def title_problem(title: str) -> str | None: + head, separator, subject = title.partition(": ") + match = HEAD.fullmatch(head) + if not separator or not match: + return 'expected "type(scope): subject", with ": " after the type or scope' + if match["type"] not in TYPES: + return f"the type must be one of {', '.join(TYPES)}" + if match["scope"] == "()": + return "the scope in parentheses is empty" + if not subject.strip() or subject[0].isspace(): + return 'the subject after ": " is missing' + return None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check a pull request title against the Conventional Commit rules." + ) + parser.add_argument("title") + title = parser.parse_args().title + problem = title_problem(title) + if problem is None: + return 0 + print( + f"The pull request title {title!r} is not a Conventional Commit title: {problem}.\n" + "Squash merges use it as the commit message, and Release Please skips it\n" + "in the changelog and version. Examples from docs/CONTRIBUTING.md:\n" + + "\n".join(f" {example}" for example in EXAMPLES), + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())