From 7aa6cc1094d457647bd64678680a6aef44e6f652 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:10:29 +0300 Subject: [PATCH] build: lint the commit subject that squash merges will create The commitlint job lints the commits on the pull request branch. Those are not what lands on develop: this repo squash-merges with squash_merge_commit_title set to COMMIT_OR_PR_TITLE, so GitHub uses the pull request title, or the head commit subject when the branch holds exactly one commit, and appends " (#N)". Nothing lints that composed string, so the release pull request is the first place it is ever checked, by which point it is immutable. This composes the same string, branching on the commit count the way GitHub does, and lints it with the same commitlint.config.mjs the commit job uses. Composing the suffix matters: header-max-length is 110 and the config sets it there to account for the pull request number, so a 110-character title passes alone and fails at 117 once the suffix is added. commitlint resolves extends relative to the config directory, so the packages are installed at the repo root rather than through npx --package, which leaves them in the npx cache where the resolver cannot find them. --- .github/workflows/commitlint-pr-title.yml | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/commitlint-pr-title.yml diff --git a/.github/workflows/commitlint-pr-title.yml b/.github/workflows/commitlint-pr-title.yml new file mode 100644 index 000000000..8574d30dd --- /dev/null +++ b/.github/workflows/commitlint-pr-title.yml @@ -0,0 +1,96 @@ +# Lint the commit subject that a squash merge will actually create. +# +# .github/workflows/commitlint.yml lints the commits on the pull request branch. +# Those commits are not what lands on `develop`: this repo squash-merges, and +# `squash_merge_commit_title` is COMMIT_OR_PR_TITLE, so GitHub composes the +# subject as either the pull request title or, when the branch holds exactly one +# commit, that commit's own subject — then appends " (#)". +# +# Nothing lints that composed string today, so non-conventional subjects reach +# `develop` through pull requests whose branch commits all passed. The release +# pull request is the first place they are ever checked, by which point they are +# immutable history. +# +# This job composes the same string and lints it with the same +# commitlint.config.mjs the commit job uses, so one ruleset governs both. + +name: Lint PR Title + +on: + pull_request: + # `edited` is the essential trigger: a title can change at any time, including + # after every other check has gone green. `synchronize` re-runs when commits + # change, which matters because the single-commit branch below reads the head + # commit's subject. + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + pr-title: + name: Lint PR Title + # Release pull requests (develop -> main) land as merge commits, whose subject + # is GitHub's auto-generated "Merge pull request #N from ...". commitlint + # ignores those by default, so the release title never becomes a linted + # subject and checking it would be pure friction. + if: ${{ !(github.head_ref == 'develop' && github.base_ref == 'main') }} + runs-on: ubuntu-latest + + concurrency: + group: commitlint-pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + + steps: + - name: Check out the repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Depth 2 brings in both parents of the pull request merge ref, so the + # head commit is present for the single-commit case below. The default + # checkout is the merge ref itself, whose subject is "Merge into + # " — never the string we want. + fetch-depth: 2 + + - name: Install commitlint + # commitlint resolves `extends` relative to the directory holding the + # config, so @commitlint/config-conventional has to be installed beside + # commitlint.config.mjs at the repo root. Installing through + # `npx --package` puts it in the npx cache instead, where the resolver + # cannot see it, and every run fails with MODULE_NOT_FOUND. + # + # There is no root package.json, and --no-save writes neither one nor a + # lockfile, so this leaves only node_modules behind. + run: | + npm install --no-save --no-audit --no-fund \ + @commitlint/cli@19.8.1 \ + @commitlint/config-conventional@19.8.1 + + - name: Lint the squash commit subject + env: + # The title is untrusted input. Passing it through the environment + # rather than interpolating it into the script keeps it out of the + # shell's parse step. + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_COMMITS: ${{ github.event.pull_request.commits }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + # Mirror COMMIT_OR_PR_TITLE. If the count is ever missing from the + # payload this falls through to the title, which is the behaviour of + # every multi-commit pull request and the common case. + if [ "${PR_COMMITS:-}" = "1" ]; then + SUBJECT=$(git log -1 --pretty=%s "$HEAD_SHA") + echo "Pull request has one commit; GitHub will use its subject." + else + SUBJECT=$PR_TITLE + echo "Pull request has ${PR_COMMITS:-?} commits; GitHub will use the title." + fi + + echo "Linting: ${SUBJECT} (#${PR_NUMBER})" + + # printf rather than echo: a subject beginning with "-" must reach + # commitlint on stdin as data, not be read as an option. + printf '%s (#%s)' "$SUBJECT" "$PR_NUMBER" \ + | npx commitlint --config commitlint.config.mjs