diff --git a/.github/check-conventional-pr-title.py b/.github/check-conventional-pr-title.py new file mode 100644 index 000000000..a1bdb9136 --- /dev/null +++ b/.github/check-conventional-pr-title.py @@ -0,0 +1,84 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Check that a PR title follows the Conventional Commits specification. + +Reads the PR title from the PR_TITLE environment variable. +Exits with a non-zero status and prints an error message if the title is invalid. + +Reference: https://www.conventionalcommits.org/en/v1.0.0/ + +This repo defines a restricted set of commit types and disallows scopes in PR titles. +""" + +from __future__ import annotations + +import os +import re +import sys + +_TYPES = frozenset({ + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'test', +}) + +# [optional scope][optional !]: +_PATTERN = re.compile( + r'^(?p[a-za-z]+)' # lower-case only, but let this be validated by _TYPES + r'(?:\((?P[^()]+)\))?' + r'(?P!)?' + r': ' + r'(?P.+)$' +) + + +def _main() -> None: + title = os.environ.get('PR_TITLE', '').strip() + if not title: + print('PR_TITLE environment variable is not set or empty.', file=sys.stderr) + sys.exit(1) + + match = _PATTERN.match(title) + if not match: + print( + f'PR title does not follow Conventional Commits format.\n' + f'Expected: [!]: \n' + f'Got: {title!r}\n' + 'Read more: https://github.com/canonical/operator/blob/main/CONTRIBUTING.md#pull-requests', + file=sys.stderr, + ) + sys.exit(1) + + scope = match.group('scope') + if scope is not None: + print( + f'Scopes are not used in PR titles.\n' + f'Got: {title!r}\n' + 'Read more: https://github.com/canonical/operator/blob/main/CONTRIBUTING.md#pull-requests', + file=sys.stderr, + ) + sys.exit(1) + + commit_type = match.group('type') + if commit_type not in _TYPES: + print( + f'Invalid type {commit_type!r} in PR title.\n' + f'Valid types: {", ".join(sorted(_TYPES))}\n' + f'Got: {title!r}\n' + 'Read more: https://github.com/canonical/operator/blob/main/CONTRIBUTING.md#pull-requests', + file=sys.stderr, + ) + sys.exit(1) + + print(f'OK: {title!r}') + + +if __name__ == '__main__': + _main() diff --git a/.github/workflows/validate-pr-title.yaml b/.github/workflows/validate-pr-title.yaml index 78459feaa..ebcca21e3 100644 --- a/.github/workflows/validate-pr-title.yaml +++ b/.github/workflows/validate-pr-title.yaml @@ -3,11 +3,10 @@ name: "Validate PR Title" # Ensure that the PR title conforms to the Conventional Commits and our choice of types and scopes, so that library version bumps can be detected automatically on: - pull_request_target: # zizmor: ignore[dangerous-triggers] Doesn't touch code - pull_request results in cancellation errors. + pull_request: types: [opened, edited, synchronize] -permissions: - pull-requests: read +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -18,18 +17,9 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@v6 with: - types: | - chore - ci - docs - feat - fix - perf - refactor - revert - test - disallowScopes: ".*" + persist-credentials: false + - run: python3 .github/check-conventional-pr-title.py + env: + PR_TITLE: ${{ github.event.pull_request.title }}