From 072fbce7739ec4b137bf890bbec5afd7a2a3ec60 Mon Sep 17 00:00:00 2001 From: Moonwalker-rgb Date: Mon, 22 Jun 2026 16:07:07 +0000 Subject: [PATCH 1/2] ci(workflows): add semantic PR title lint workflow Adds .github/workflows/pr-title-lint.yml using amannn/action-semantic-pull-request@v5.5.0 to enforce Conventional Commits-style PR titles on every PR open, edit, reopen, and synchronize against main. The workflow: - explicitly pins the allowed commit types (matches CONTRIBUTING.md) - uses pull_request (not pull_request_target) for fork-PR safety - grants only the minimum permissions the action needs - cancels superseded runs on PR title iteration Also tightens CONTRIBUTING.md PR guidelines to reference the new lint workflow so contributors see the automated enforcement. Closes #36 --- .github/workflows/pr-title-lint.yml | 95 +++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-title-lint.yml diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 0000000..d39c040 --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -0,0 +1,95 @@ +name: Lint PR Title + +# Enforce Conventional Commits-style PR titles so the changelog +# generator and release tooling can categorize changes automatically. +# See CONTRIBUTING.md -> "Commit message format (Conventional Commits)" +# for the spec contributors should follow. +# +# This complements (but does not replace) the existing CI matrix in +# ci.yml: ci.yml runs build/test/lint on PR *code*, this workflow +# validates the PR *title* metadata only. +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + branches: [main] + +# Cancel any in-flight run for the same PR when a new commit pushes +# supersede it. Authors iterate on PR titles, so this saves minutes. +concurrency: + group: pr-title-lint-${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +# Principle of least privilege: the action only needs to read PR +# metadata and write the commit status check. No code is checked out, +# so we deliberately do NOT set `pull_request_target` (which would +# run against the base branch and expose secrets to fork PRs). +permissions: + contents: read + pull-requests: read + statuses: write + +jobs: + validate: + name: Validate PR title (Conventional Commits) + # No checkout step is needed: the action reads PR metadata from + # the GitHub API via GITHUB_TOKEN, not from the working tree. + runs-on: ubuntu-latest + steps: + - name: Check PR title + uses: amannn/action-semantic-pull-request@v5.5.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Allowed conventional-commit types. Pinned explicitly so an + # upstream default change in the action cannot silently relax + # our policy. Mirrors the list documented in CONTRIBUTING.md. + # Encoded as a literal JSON string (`|`, not `>-`) so the JSON + # is byte-for-byte and reformatting it cannot accidentally + # produce a malformed payload. + types: | + [ + "feat", + "fix", + "chore", + "docs", + "style", + "refactor", + "perf", + "test", + "build", + "ci", + "revert" + ] + # Subject pattern: ()!: + # - type: one of the entries in `types` (enforced separately) + # - scope: OPTIONAL. Kept optional on purpose: CONTRIBUTING.md + # shows scope in the format spec but the existing + # commit log mixes scoped and unscoped titles + # (e.g. "perf(backend): ..." vs. "fix: ..."), and + # issue #36 itself is unscoped ("ci: add ..."). Make + # scope mandatory only if/when the team adopts a + # stricter convention. + # - !: OPTIONAL, marks a breaking change (Conventional + # Commits spec). + # - summary: present tense, no trailing period. Length is not + # bound here so a deliberate, descriptive title is + # still allowed; the action surfaces a check status + # either way. + subjectPattern: '^(\w+)(?:\([\w\-\.\/]+\))?!?: .+$' + subjectPatternError: >- + The PR title does not follow Conventional Commits. + Expected format: (): + + Allowed types: feat, fix, chore, docs, style, refactor, + perf, test, build, ci, revert. + + Examples: + feat(api): add user authentication endpoint + fix(ui): correct navigation dropdown focus + ci(workflows): add semantic PR title linting + + See CONTRIBUTING.md for the full spec. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddaef56..5e9dfa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -363,7 +363,7 @@ npm run test ### Pull Request Guidelines -- **Title**: Use a clear, descriptive title following Conventional Commits format +- **Title**: Use a clear, descriptive title following Conventional Commits format. This is enforced automatically by the [`pr-title-lint.yml`](.github/workflows/pr-title-lint.yml) workflow on every PR open / edit / reopen / synchronize, so non-conforming titles will block merge. See [Commit message format (Conventional Commits)](#commit-message-format-conventional-commits) below for the spec. - **Description**: Explain what changes you made and why - **Tests**: Ensure all tests pass - **Documentation**: Update relevant documentation if needed From 81e032e8f08777d00113000c4cc6645356b3feaa Mon Sep 17 00:00:00 2001 From: Moonwalker-rgb Date: Mon, 22 Jun 2026 16:20:30 +0000 Subject: [PATCH 2/2] fix(workflows): use newline-delimited types and subject-only pattern in pr-title-lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two latent bugs surfaced when the workflow ran against PR #79: 1. `types` was encoded as a multi-line JSON array in YAML. The amannn action ships a ConfigParser that splits the types input by `\n` and trims/filters empties — it does NOT parse a JSON array. The earlier YAML therefore silently produced a 13-element garbage list (`"[`, ` "feat",`, `"ci",`, `]`, …) so `types.includes(result.type)` rejected every title under the message "Unknown release type". Switching to newline-delimited plaintext (one type per line). 2. `subjectPattern` was previously `^(\w+)(?:\([\w\-\.\/]+\))?!?: .+$`, intended for the FULL title. The action matches it against the parsed bare `subject` (after conventional-commits-parser strips the `()!:` prefix), so the regex would never have matched a real subject. Replaced with `^.{1,72}$` enforcing CONTRIBUTING.md's `<= 72 chars` rule, with an updated error message that points contributors at CONTRIBUTING.md. Also updated inline comments so a future maintainer cannot reintroduce the same two bugs. Closes #36 --- .github/workflows/pr-title-lint.yml | 70 ++++++++++++++--------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml index d39c040..75d5511 100644 --- a/.github/workflows/pr-title-lint.yml +++ b/.github/workflows/pr-title-lint.yml @@ -44,45 +44,41 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - # Allowed conventional-commit types. Pinned explicitly so an - # upstream default change in the action cannot silently relax - # our policy. Mirrors the list documented in CONTRIBUTING.md. - # Encoded as a literal JSON string (`|`, not `>-`) so the JSON - # is byte-for-byte and reformatting it cannot accidentally - # produce a malformed payload. + # Allowed conventional-commit types, one per line. NOTE: + # `amannn/action-semantic-pull-request`'s ConfigParser splits + # this input by `\n` and trims each line — it does NOT parse + # a JSON array. So `types: |` with a `["feat",...]` array + # silently produces a garbage list of 13 entries (`[`, + # `"feat",`, `"ci",`, …) and `result.type` fails to match. + # Pinned explicitly so an upstream default change in the + # action cannot silently relax our policy. Mirrors the list + # documented in CONTRIBUTING.md. types: | - [ - "feat", - "fix", - "chore", - "docs", - "style", - "refactor", - "perf", - "test", - "build", - "ci", - "revert" - ] - # Subject pattern: ()!: - # - type: one of the entries in `types` (enforced separately) - # - scope: OPTIONAL. Kept optional on purpose: CONTRIBUTING.md - # shows scope in the format spec but the existing - # commit log mixes scoped and unscoped titles - # (e.g. "perf(backend): ..." vs. "fix: ..."), and - # issue #36 itself is unscoped ("ci: add ..."). Make - # scope mandatory only if/when the team adopts a - # stricter convention. - # - !: OPTIONAL, marks a breaking change (Conventional - # Commits spec). - # - summary: present tense, no trailing period. Length is not - # bound here so a deliberate, descriptive title is - # still allowed; the action surfaces a check status - # either way. - subjectPattern: '^(\w+)(?:\([\w\-\.\/]+\))?!?: .+$' + feat + fix + chore + docs + style + refactor + perf + test + build + ci + revert + # Subject validator. The action runs the title through + # conventional-commits-parser first (built-in headerPattern) + # which extracts `type`, `scope`, and `subject`. The action + # then checks `subjectPattern` against the parsed `subject` + # ONLY (the bare summary), NOT the full title. + # + # We just enforce what CONTRIBUTING.md states: non-empty and + # ≤ 72 characters. Kept permissive otherwise so descriptive + # titles still pass. + subjectPattern: '^.{1,72}$' subjectPatternError: >- - The PR title does not follow Conventional Commits. - Expected format: (): + The PR subject (everything after the type/scope prefix) must be + non-empty and ≤ 72 characters. Expected title shape: + (): Allowed types: feat, fix, chore, docs, style, refactor, perf, test, build, ci, revert.